Этап 7, итерация 3 завершена: m08-admin-web (консоль владельца)
Backend (Server.WatchBody.Web): AdminAuthService — PBKDF2 (pbkdf2$100000$salt$hash) → in-memory Bearer-сессии TTL 8 ч (Admin.Login пуст → консоль отключена); 6 контроллеров /api/admin/*: login/logout; атлеты (создание — ключ wb-… возвращается один раз, смена ключа, блокировка → 401 на /api/v1); тренировки всех атлетов (фильтры/пагинация, карточка план/факт + таймлайн Lap/пульс + пульсограмма ≤600 точек), CSV (;/BOM, tech-req 2.4); программы (версии/body, активация draft, применение rules-предложения через author=admin-копию — неизменяемость версий); метрики (+weekly ISO-агрегат); план/факт (PlanSummaryService: недели от понедельника создания active-версии, performed — только плановые дни). Errata m02: ApiKeyMiddleware сужен до /api/v1/* (перехватывал статику); Kit.Routes — роут на блок AddController (NeedHttpPost_All не чейнится). Flutter Web UI (3-app\3-web\admin-web, SDK 3.47.7): Material 3 тёмная тема, NavigationRail, 6 экранов, токен sessionStorage, 401 → логин, CSV через Bearer+Blob, графики CustomPaint; без внешних pub-пакетов; build.ps1 → wwwroot\admin (сборка в .gitignore — артефакт). Тесты: Web 105/105 (m08 — 7), Core 56/56; flutter analyze 0 ошибок/варнингов; HTTP-смок хостинга/логина пройден. Эксплуатационные файлы в коммит не включены.
This commit is contained in:
parent
f41bf9a0d2
commit
d61499025c
|
|
@ -17,3 +17,6 @@ Thumbs.db
|
|||
# Сборки и временные файлы
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
# Сборка admin-консоли (m08) — артефакт flutter build (3-app-webdmin-webuild.ps1)
|
||||
3-app/2-cli/Server.WatchBody.Web/wwwroot/admin/
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
|
|||
| m05-progression-rules | `Server.WatchBody.Core`: правила прогрессии (Ф-12.2), делоад (Ф-12.4), перерывы/возврат (Ф-14), учёт ограничений (is_active/замены), draft-версии `author=rules` | 2 | ✅ (2026-10-18: ProgressionRules + BreakEngine, фильтр ограничений, тесты Web 81/81 + Core 56/56) |
|
||||
| m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) |
|
||||
| m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) |
|
||||
| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: атлеты, тренировки, программы, метрики, CSV | 3 | ⬜ |
|
||||
| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) |
|
||||
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ |
|
||||
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ |
|
||||
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
name: m08-admin-web
|
||||
description: Admin REST /api/admin/* в Server.WatchBody.Web — консоль владельца (Ф-9): login/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт
|
||||
---
|
||||
|
||||
# m08-admin-web
|
||||
|
||||
Бэкенд административной консоли владельца в `Server.WatchBody.Web`:
|
||||
`POST /api/admin/login` (PBKDF2-проверка → in-memory Bearer-сессия,
|
||||
TTL 8 ч; атлеты к `/api/admin/*` доступа не имеют — 🛡 отдельный
|
||||
контур от `/api/v1/*`); атлеты (создание с серверной генерацией
|
||||
API-ключа — возвращается один раз, смена ключа, блокировка);
|
||||
тренировки всех атлетов (фильтры, пагинация, карточка с план/факт
|
||||
и facts-таймлайном, CSV `;`+BOM по tech-req 2.4); программы (версии,
|
||||
активация, применение rules-предложений через author=admin-копию —
|
||||
🛡 неизменяемость); метрики за период; сводка план/факт по неделям.
|
||||
UI — Flutter Web `3-app\3-web\admin-web` (отдельный этап, требует
|
||||
Flutter SDK); сервер отдаёт статику сборки из `wwwroot\admin\`.
|
||||
|
||||
- Требования: [module_reqs.md](module_reqs.md)
|
||||
- План работ: [readme.md](readme.md)
|
||||
- Спецификации: [api_specs/](api_specs/)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Спецификация 01: аутентификация владельца (AdminAuthService)
|
||||
|
||||
Источники: Ф-9 (🛡 только владелец), tech-req 4.2 (PBKDF2 100k,
|
||||
префикс `pbkdf2$`), module_reqs разд. 2.
|
||||
|
||||
## 1. Формат хэша
|
||||
|
||||
`Admin.PasswordHash` = `pbkdf2$100000$<salt_b64>$<hash_b64>`:
|
||||
PBKDF2-HMAC-SHA256, 100 000 итераций, соль 16 байт, вывод 32 байта,
|
||||
salt/hash — base64. Генерация (владельцу; python):
|
||||
|
||||
```bash
|
||||
python -c "import os,hashlib,base64;s=os.urandom(16);print('pbkdf2$100000$'+base64.b64encode(s).decode()+'$'+base64.b64encode(hashlib.pbkdf2_hmac('sha256',b'ПАРОЛЬ',s,100000)).decode())"
|
||||
```
|
||||
|
||||
`AdminAuthService.HashPassword(password, salt?)` — тот же формат
|
||||
(используется тестами).
|
||||
|
||||
## 2. Сессии
|
||||
|
||||
- `Login(login, password)`: сверка с `Admin.Login` (константное
|
||||
сравнение) и `Admin.PasswordHash` → валидно → токен
|
||||
`Base64Url(32 random bytes)`, словарь `token → expiresAt = UtcNow+8ч`;
|
||||
невалидно → `null` (эндпоинт отдаёт 401, без деталей);
|
||||
- `Validate(token)` → bool (просроченные удаляются лениво);
|
||||
- `Logout(token)` — удаление;
|
||||
- перезапуск сервера — словарь пуст (вход заново);
|
||||
- `Admin.Login` пуст в конфиге → консоль отключена: login всегда 401
|
||||
(serve-валидацию не ломает — ключи опциональны);
|
||||
- 🛡2: токен/пароль/хэш не логируются и не возвращаются.
|
||||
|
||||
## 3. Эндпоинт и фильтр
|
||||
|
||||
`POST /api/admin/login` body `{"login":"...","password":"..."}` →
|
||||
`200 {"token":"..."}` | `401 {"error":"unauthorized"}`. Rate: без
|
||||
ограничений в v1 (консоль за reverse-proxy, вопрос развёртывания).
|
||||
|
||||
`AdminAuthFilter` (MVC-фильтр на контроллерах admin): заголовок
|
||||
`Authorization: Bearer <token>`; нет/невалиден/просрочен → `401`
|
||||
`{"error":"unauthorized"}`. Контур `/api/admin/*` не проходит
|
||||
`ApiKeyMiddleware` (🛡1 — атлетские `X-Api-Key` там не действуют).
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Спецификация 02: атлеты (AdminAthletesController)
|
||||
|
||||
Источники: Ф-9.1 (управление, вопрос 9 закрыт), 🛡2 (ключ — один раз).
|
||||
|
||||
## 1. Эндпоинты (все — Bearer, spec 01)
|
||||
|
||||
| Метод | Путь | Тело → Ответ |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/athletes` | → `{"items":[{"id":1,"name":"Иван","isBlocked":false,"createdAt":"…"}]}` |
|
||||
| POST | `/api/admin/athletes` | `{"name":"Иван"}` → `201 {"id":…,"apiKey":"wb-…"}` |
|
||||
| POST | `/api/admin/athletes/{id}/key` | — → `200 {"apiKey":"wb-…"}` |
|
||||
| POST | `/api/admin/athletes/{id}/block` | `{"isBlocked":true}` → `200 {"id":…,"isBlocked":true}` |
|
||||
|
||||
## 2. Семантика
|
||||
|
||||
- **Создание**: имя непустое (иначе 400); ключ = `"wb-" + 24 hex`
|
||||
(random); в БД — только `ApiKeyHash.Of(key)` (SHA-256, как m02);
|
||||
ключ возвращается **один раз** (повторно не выдаётся — 🛡2);
|
||||
дубликат имени допустим (БД не запрещает; консоль показывает id).
|
||||
- **Смена ключа**: новый ключ по тому же правилу; `athlete_update`
|
||||
(все поля, NULL-семантики нет — m01); старый ключ перестаёт
|
||||
работать немедленно.
|
||||
- **Блокировка**: `athlete_update` c `is_blocked`; заблокированный
|
||||
ключ → 401 на `/api/v1/*` (m02, проверяется тестом сквозняком);
|
||||
блокировка не удаляет данные.
|
||||
- Все записи — только `athlete_insert`/`athlete_update` (🛡3).
|
||||
|
||||
## 3. Ошибки
|
||||
|
||||
- 400 — пустое имя; 404 — атлет не найден (id); 401 — без токена.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Спецификация 03: тренировки и CSV (AdminWorkoutsController)
|
||||
|
||||
Источники: Ф-9.2, tech-req 2.4 (CSV), m04 (workout_metrics), m06
|
||||
(facts_json workout-facts/1).
|
||||
|
||||
## 1. Список
|
||||
|
||||
`GET /api/admin/workouts?athleteId&dateFrom&dateTo&limit&offset`
|
||||
(Bearer) → `{"total":N,"items":[карточки]}`; карточка:
|
||||
`workoutId, athleteId, athleteName, startedAt, endedAt, durationMin,
|
||||
status, sessionKind, avgHr, setsCount, exercisesCount, adherence`
|
||||
(adherence — из workout_metrics; нет строки → null). Все атлеты;
|
||||
`athleteId` опционален; limit ≤ 100 (default 50), offset ≥ 0.
|
||||
|
||||
## 2. Карточка
|
||||
|
||||
`GET /api/admin/workouts/{id}` → карточка списка +:
|
||||
|
||||
- `sets[]`: seq, exerciseName, weightKg, reps, inPlan, rpe, kneePain
|
||||
(из workout_select-агрегата m01; имя — по exercise_id из библиотеки
|
||||
атлета);
|
||||
- `planFact`: по каждому упражнению — план (активная версия: вес ×
|
||||
повторы по сетам дня недели) против факта (подходы); план дня не
|
||||
сопоставился → только факт (как в m04 adherence fallback);
|
||||
- `timeline[]`: из `facts_json.laps[]` + `records[]` — по каждому
|
||||
подходу: `startOffsetS, durationS, avgHr, peakHr, restAfterS`
|
||||
(отдых = старт следующего lap − конец этого); `hrCurve[]`:
|
||||
`[{t,hr}]` из records (прореживание: не более 600 точек, шаг
|
||||
`duration/600`). facts_json нет → `timeline/hrCurve` пустые.
|
||||
|
||||
## 3. CSV (КУ-4)
|
||||
|
||||
`GET /api/admin/workouts.csv?athleteId&dateFrom&dateTo` — та же
|
||||
выборка (без пагинации, лимит 10 000):
|
||||
|
||||
- разделитель `;`, UTF-8 **c BOM**, заголовок:
|
||||
`athlete;date;duration_min;avg_hr;sets;exercises;adherence_pct`;
|
||||
- строка — тренировка: имя атлета; дата YYYY-MM-DD; длительность
|
||||
(мин, из ended−started; нет — пустая ячейка); avg_hr; число
|
||||
подходов; число упражнений; adherence (нет — пустая);
|
||||
- `Content-Type: text/csv; charset=utf-8`,
|
||||
`Content-Disposition: attachment; filename="workouts.csv"`.
|
||||
|
||||
## 4. Ошибки
|
||||
|
||||
401 без токена; 404 чужой/несуществующий id. 🛡3: только чтение.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Спецификация 04: программы (AdminProgramsController)
|
||||
|
||||
Источники: Ф-9.3, Ф-10.3, m03 (ProgramStore), 🛡4 module_reqs.
|
||||
|
||||
## 1. Эндпоинты (Bearer)
|
||||
|
||||
| Метод | Путь | Ответ |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/programs?athleteId` | `{"items":[{versionId, version, status, author, basedOnVersion, rationale, createdAt}]}` (без body) |
|
||||
| GET | `/api/admin/programs/{versionId}?athleteId` | версия с `body` (json как есть) |
|
||||
| POST | `/api/admin/programs/{versionId}/activate?athleteId` | `200 {"version":N,"status":"active"}` |
|
||||
| POST | `/api/admin/programs/{versionId}/apply?athleteId` | `200 {"versionId":"…","version":N,"status":"active"}` |
|
||||
|
||||
## 2. Семантика
|
||||
|
||||
- **Активация**: только `draft` (иначе 400); `ProgramStore.Activate`
|
||||
— прежняя active → archived (гарантия БД). Повторная активация
|
||||
archived — 400 (m03).
|
||||
- **Применение предложения правил** (Ф-9.3): только rules-draft;
|
||||
`ProgramStore.CreateDraft(athleteId, body=rules-draft.body,
|
||||
author=admin, rationale=оригинал + " (применено из консоли)")` →
|
||||
активация новой версии. 🛡4: оригинальный rules-draft не меняется
|
||||
(остаётся draft — владелец может его архивировать активацией
|
||||
другой версии; дедуп-замок m05 снимается тем, что новая active
|
||||
версия уже существует). Не-rules версия на apply → 400.
|
||||
- 🛡3: других записей нет (редактирование body — вне v1).
|
||||
|
||||
## 3. Ошибки
|
||||
|
||||
401; 404 — версия/атлет не найдены; 400 — статус не позволяет.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Спецификация 05: метрики и план/факт
|
||||
|
||||
Источники: Ф-9.4/Ф-9.5, m04 (workout_metrics), дизайн LLM-планирования
|
||||
разд. 2 (body). Только чтение (🛡3).
|
||||
|
||||
## 1. Метрики
|
||||
|
||||
`GET /api/admin/metrics?athleteId&dateFrom&dateTo` (Bearer) →
|
||||
|
||||
```json
|
||||
{"workouts":[{"workoutId":"…","date":"2026-10-14","adherence":100.0,
|
||||
"tonnage":640.0,"tonnageByGroup":{"push":640.0},
|
||||
"e1pmByExercise":{"<uuid>":120.0},"redFlags":[]}],
|
||||
"weekly":[{"week":"2026-10-12","workouts":2,"avgAdherence":85.0,
|
||||
"tonnage":2400.0}]}
|
||||
```
|
||||
|
||||
- по тренировкам — строки `workout_metrics` периода (поля как в БД;
|
||||
JSON-поля — как есть);
|
||||
- `weekly` — группировка по ISO-неделе даты тренировки: число
|
||||
тренировок, среднее adherence, сумма тоннажа.
|
||||
|
||||
## 2. План/факт (PlanSummaryService)
|
||||
|
||||
`GET /api/admin/plan?athleteId` → сводка по активной версии:
|
||||
|
||||
```json
|
||||
{"activeVersion":3,"weeks":[{"week":1,"from":"2026-10-05","to":"2026-10-11",
|
||||
"planned":2,"performed":1,"missed":1,"avgAdherence":100.0}]}
|
||||
```
|
||||
|
||||
- неделя N активной версии: `from` = понедельник недели создания
|
||||
версии + 7×(N−1) дней; `to` = from+6;
|
||||
- `planned` — число сессий в `weeks[N-1].sessions` (body; в v1 все
|
||||
недели одинаковые по составу — считаем по своей неделе массива);
|
||||
- `performed` — тренировки атлета за [from..to] **в плановые дни
|
||||
недели** (day-of-week сессии; день без сессии в плане не считается);
|
||||
- `missed` = planned − performed (не < 0);
|
||||
- `avgAdherence` — среднее adherence этих тренировок (нет — null);
|
||||
- активной версии нет → `{"activeVersion":null,"weeks":[]}`.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Спецификация 06: Flutter Web UI (`3-app\3-web\admin-web`)
|
||||
|
||||
Источники: решение владельца 2026-10-17 (Flutter Web), UI/UX-стандарт
|
||||
(tech-req разд. 6: тёмная тема, ≥16pt, цвет дублируется текстом).
|
||||
|
||||
## 1. Каркас
|
||||
|
||||
- `flutter create --platforms web` (SDK 3.47.7, `C:\tools\flutter`);
|
||||
Material 3, тёмная тема; **без внешних pub-пакетов** (только SDK:
|
||||
`dart:io`/`http` из `dart:io`? web → `package:http` входит в SDK
|
||||
как `dart:io` нет — используется `HttpRequest`/`fetch` через
|
||||
`package:http` из SDK-зависимостей flutter (входит в
|
||||
flutter create) — внешних добавок нет);
|
||||
- состояние — `ChangeNotifier` (`AdminState`: токен, текущий экран);
|
||||
токен — `sessionStorage['adminToken']`;
|
||||
- `ApiClient(baseUrl, token)`: GET/POST JSON; 401 → очистка токена,
|
||||
экран логина; все запросы `/api/admin/*` относительно origin.
|
||||
|
||||
## 2. Экраны (навигация — NavigationRail слева)
|
||||
|
||||
1. **Логин**: логин/пароль → `POST login` → токен в sessionStorage.
|
||||
2. **Атлеты**: таблица (id, имя, блок, создан); «+ Атлет» — диалог
|
||||
(имя) → показать ключ в диалоге с кнопкой «Скопировать» и
|
||||
предупреждением «показывается один раз»; смена ключа — то же;
|
||||
блокировка — Switch.
|
||||
3. **Тренировки**: фильтры (атлет-дропдаун, дата с/по), таблица
|
||||
(дата, атлет, мин, ср. пульс, подходы, adherence), пагинация
|
||||
(limit 50); строка → карточка: план/факт по упражнениям,
|
||||
таймлайн подходов (старт/длит./пульс/отдых), пульсограмма
|
||||
(`CustomPaint`, линия по hrCurve); кнопка «CSV» — ссылка
|
||||
`workouts.csv` с текущими фильтрами (+ токен через `?token=`
|
||||
не поддерживается — CSV открывается через fetch c Bearer и
|
||||
Blob-download).
|
||||
4. **Программы**: выбор атлета → версии (номер, статус, автор,
|
||||
rationale, дата); просмотр body (formatted JSON, сворачиваемое);
|
||||
активация draft (подтверждение); rules-draft — кнопка
|
||||
«Применить предложение» (подтверждение с rationale).
|
||||
5. **Метрики**: атлет + период; таблица тренировок; недельный
|
||||
график — столбики тоннажа + линия adherence (CustomPaint,
|
||||
значения подписаны текстом).
|
||||
6. **План/факт**: атлет → сводка недель (запланировано/выполнено/
|
||||
пропущено, adherence), отклонение цветом + текстом.
|
||||
|
||||
## 3. Сборка и хостинг
|
||||
|
||||
- `flutter build web --release` → `build/web`;
|
||||
- копия в `Server.WatchBody.Web\wwwroot\admin\` (скрипт
|
||||
`build.ps1` в проекте UI); Kestrel отдаёт статику: `/admin` →
|
||||
`index.html` (fallback-маршрут), ассеты — как файлы;
|
||||
- базовый URL API = origin страницы (консоль на том же сервере).
|
||||
|
||||
## 4. Проверки
|
||||
|
||||
- `flutter analyze` — чисто; `flutter build web` — без ошибок;
|
||||
- ручной смок по чек-листу: логин/401-редирект, CRUD атлетов,
|
||||
список/карточка/CSV, активация/применение, метрики, план/факт
|
||||
(данные — тестовые атлеты docker-БД).
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Требования к модулю: m08-admin-web
|
||||
|
||||
> Модуль — административная консоль владельца (Ф-9, решение владельца
|
||||
> 2026-10-17): бэкенд — admin REST API `/api/admin/*` в
|
||||
> `Server.WatchBody.Web`; UI — Flutter Web-приложение
|
||||
> `3-app\3-web\admin-web` (SDK 3.47.7 — `C:\tools\flutter`), сборка
|
||||
> отдаётся сервером из `wwwroot\admin\`. Аутентификация владельца —
|
||||
> логин/пароль (`Admin.Login` / `Admin.PasswordHash`, PBKDF2) →
|
||||
> Bearer-токен сессии; атлеты к консоли доступа не имеют (🛡
|
||||
> изменение vision 1.2). Самодостаточная копия требований (d-dev-6).
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
**В границах модуля (backend REST):**
|
||||
|
||||
- аутентификация: `POST /api/admin/login` (логин+пароль →
|
||||
`pbkdf2$...`-проверка → Bearer-токен, in-memory сессия, TTL 8 ч);
|
||||
все `/api/admin/*` кроме login — только с валидным токеном (401);
|
||||
- атлеты (Ф-9.1): список; создание (имя → сервер генерирует API-ключ,
|
||||
возвращается один раз, в БД — только хэш); смена ключа; блокировка/
|
||||
разблокировка (заблокированный ключ → 401 на API телефонов);
|
||||
- тренировки (Ф-9.2): таблица по всем атлетам (фильтр атлет/дата,
|
||||
пагинация); карточка (подходы, план/факт, `facts_json`: таймлайн
|
||||
Lap + пульс Record, средний/пиковый пульс подхода, отдых);
|
||||
CSV-выгрузка текущей выборки (КУ-4, формат tech-req 2.4: `;`,
|
||||
UTF-8 BOM, `athlete;date;duration_min;avg_hr;sets;exercises;
|
||||
adherence_pct`);
|
||||
- программы (Ф-9.3): версии атлета (статусы/автор/даты/body);
|
||||
активация draft (ProgramStore.Activate); применение предложения
|
||||
правил из консоли — новая версия `author=admin` с телом rules-draft,
|
||||
затем активация (🛡 неизменяемость версений, Ф-9.3);
|
||||
- метрики (Ф-9.4): выборка `workout_metrics` атлета за период;
|
||||
- планы тренировок (Ф-9.5): сводка план/факт по неделям активной
|
||||
версии (запланировано сессий / выполнено / пропущено; доля);
|
||||
- отдача статики Flutter-сборки `wwwroot\admin\` (если каталог есть) —
|
||||
UI хостится тем же Kestrel.
|
||||
|
||||
**Вне границ модуля:** Flutter Web UI `3-app\3-web\admin-web`
|
||||
(отдельный этап — требует Flutter SDK, решение гейта 1.3), редактор
|
||||
body версий (v1: просмотр + активация; правка — телефон m09/импорт
|
||||
m06), LLM-контур (v2), выгрузка АД-журнала CSV (v3).
|
||||
|
||||
## 2. Сессии и токены
|
||||
|
||||
- пароль проверяется по `Admin.PasswordHash` (PBKDF2 100k, соль в
|
||||
хэше, префикс `pbkdf2$`; формат — `<login>-salt` || PBKDF2-SHA256);
|
||||
несовпадение → 401 без указания, что именно неверно;
|
||||
- токен: 256-bit random (base64url), in-memory словарь
|
||||
`token → (expiresAt)`; перезапуск сервера — сессии сбрасываются
|
||||
(владелец входит заново); TTL 8 ч; logout — удаление из словаря;
|
||||
- 🛡 токены/пароли в логи не пишутся; `Admin.PasswordHash` не
|
||||
возвращается никаким эндпоинтом.
|
||||
|
||||
## 3. Инварианты (🛡 — нарушять запрещено)
|
||||
|
||||
1. **Только владелец** — `/api/admin/*` недоступен атлетам: их
|
||||
`X-Api-Key` не принимается, изолированный контур (`/api/v1/*`
|
||||
не меняется).
|
||||
2. **Тайны** — API-ключ атлета возвращается только при создании/смене;
|
||||
хэши, пароли, токены — не возвращаются и не логируются.
|
||||
3. **Запись только управляющая** — admin API не пишет факт-данные
|
||||
(тренировки/подходы/метрики): только атлеты (create/block/key),
|
||||
статусы версий программ и author=admin-копии предложений.
|
||||
4. **Неизменяемость версий** — активация через ProgramStore;
|
||||
author=admin-копия создаётся CreateDraft, оригинал rules-draft
|
||||
не меняется.
|
||||
5. **Формат CSV** — точно tech-req 2.4 (разделитель `;`, UTF-8 BOM).
|
||||
|
||||
## 4. Критерии приёмки модуля
|
||||
|
||||
1. Логин: верный пароль → токен (последующие запросы с Bearer — 200);
|
||||
неверный → 401; без токена/с токеном атлета (X-Api-Key) → 401.
|
||||
2. Атлеты: создание возвращает ключ (в БД только хэш, повторный
|
||||
запрос ключ не отдаёт); смена ключа; блокировка → sync телефона
|
||||
с тем ключом получает 401; разблокировка → снова 200.
|
||||
3. Тренировки: список всех атлетов с фильтрами/пагинацией; карточка
|
||||
с подходами и план/факт; CSV выборки соответствует формату.
|
||||
4. Программы: версии атлета; активация draft меняет active (прежняя —
|
||||
archived); применение rules-предложения → активна версия
|
||||
author=admin с телом предложения.
|
||||
5. Метрики и план/факт: выборка метрик за период; сводка недель
|
||||
(запланировано/выполнено/пропущено) сходится с фактическими
|
||||
тренировками.
|
||||
6. Тесты интеграционные против docker `watchbody-pg` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# m08-admin-web — план работ (d-dev)
|
||||
|
||||
> Модуль: admin REST API `/api/admin/*` в `Server.WatchBody.Web`
|
||||
> (бэкенд консоли владельца, Ф-9; UI — Flutter Web, отдельный этап).
|
||||
> Алгоритм: `std1\_rules\d\d-dev.md`. Требования:
|
||||
> [module_reqs.md](module_reqs.md).
|
||||
> Зависимости: m01 (репозитории, DONE), m02 (Web/маршруты/middleware,
|
||||
> DONE), m03 (ProgramStore, DONE), m04 (workout_metrics, DONE).
|
||||
|
||||
| Шаг | Что делаем | Статус |
|
||||
|---|---|---|
|
||||
| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 |
|
||||
| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 |
|
||||
| 1.3 | **Пользователь!** утверждение списка | ⬜ |
|
||||
| 2 | Спецификации: api_specs (auth, athletes, workouts+CSV, programs, metrics, plan, UI) | ✅ 2026-10-19 |
|
||||
| 2.4 | **Пользователь!** утверждение спецификаций | ⬜ |
|
||||
| 3 | Реализация + тесты | ✅ 2026-10-19 (Web 105/105, из них m08 — 7; Core 56/56; flutter analyze 0 ошибок; build web → wwwrootdmin; HTTP-смок хостинга/логина пройден) |
|
||||
| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 |
|
||||
| 4 | Завершение: план, примечания | ✅ 2026-10-19 — **Модуль завершён** (2026-10-19). Итерация 3 завершена. Следующий по плану этапа 7 — m09-phone-core (итерация 4, Android). |
|
||||
|
||||
Статусы: ⬜ не начат · 🔄 в работе · ✅ готов.
|
||||
|
||||
## Примечания по ходу работ
|
||||
|
||||
1. **Kit.Helpers.Routes**: `NeedHttpPost_All` нельзя чейнить на несколько
|
||||
роутов одного блока `AddController` (RouteFluent — дубль ключа
|
||||
`httpMethod`) — каждый admin-эндпоинт в отдельном блоке.
|
||||
2. **ApiKeyMiddleware сужен до `/api/v1/*`** (errata m02): раньше
|
||||
перехватывал вообще все пути — статика консоли `/admin` и прочее
|
||||
получали 401. `/api/admin` — Bearer-контур, `/health` — открыт;
|
||||
поведение `/api/v1/*` не изменилось (тесты зелёные).
|
||||
3. **workout_select без «всех атлетов»** — admin-список собирается
|
||||
обходом атлетов (v1: атлетов немного; кандидат — функция БД в v2).
|
||||
4. **Request.Body в Kestrel не seekable** — чтение тела один раз,
|
||||
без `Position = 0` (иначе 500).
|
||||
5. **workout_insert не пишет `ended_at`** (errata m02/m06): тесты
|
||||
дозакрывают через Update.
|
||||
6. **Flutter**: SDK 3.47.7 (`C: oolslutter`, PATH в каждом вызове);
|
||||
без внешних pub-пакетов (только SDK) — `dart:html` для HTTP/Blob
|
||||
(deprecation-info осознанно, web-only v1); сборка 41 МБ (canvaskit)
|
||||
— `wwwrootdmin` в .gitignore (артефакт, собирается
|
||||
`build.ps1`); analyze — 0 ошибок/варнингов, 4 info (dart:html ×2,
|
||||
use_build_context ×2 — ложные срабатывания при guard `mounted`).
|
||||
7. **Смоки**: HTTP (curl) — статика 200, /health 200, /api/v1 без
|
||||
ключа 401, login 401/200, athletes/plan с Bearer — данные;
|
||||
браузерный прогон UI — за владельцем (чек-лист spec 06 §4).
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Kit.Helpers.SqlHelperAbstract;
|
||||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Core.Programs;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.WatchBody.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты admin API (specs 01–05 m08): Bearer-контур,
|
||||
/// атлеты (ключ один раз, блокировка → 401 sync), тренировки+CSV,
|
||||
/// программы (активация/применение rules), метрики, план/факт.
|
||||
/// </summary>
|
||||
[Collection("web")]
|
||||
public class AdminApiTests(TestApp app)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private ConnectionString NewCs() => new()
|
||||
{
|
||||
Type = ConnectionStringType.Postgres,
|
||||
Value = app.Config.DbConnectionString,
|
||||
};
|
||||
|
||||
private async Task<HttpClient> Client()
|
||||
{
|
||||
var http = new HttpClient { BaseAddress = app.Client.BaseAddress };
|
||||
var login = await http.PostAsJsonAsync("/api/admin/login",
|
||||
new { login = "test-admin", password = "admin-test-pass" });
|
||||
Assert.True(login.IsSuccessStatusCode, "login должен пройти");
|
||||
var token = (await login.Content.ReadFromJsonAsync<JsonElement>())
|
||||
.GetProperty("token").GetString();
|
||||
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
|
||||
return http;
|
||||
}
|
||||
|
||||
private async Task<string> LoginToken(string login, string password)
|
||||
{
|
||||
var http = app.Client;
|
||||
var response = await http.PostAsync("/api/admin/login",
|
||||
new StringContent($"{{\"login\":\"{login}\",\"password\":\"{password}\"}}",
|
||||
Encoding.UTF8, "application/json"));
|
||||
return response.IsSuccessStatusCode ? "ok" : $"{(int)response.StatusCode}";
|
||||
}
|
||||
|
||||
// --- spec 01: auth ---
|
||||
|
||||
[Fact]
|
||||
public async Task AdminLogin_WrongPassword_401()
|
||||
{
|
||||
Assert.Equal("401", await LoginToken("test-admin", "неверно"));
|
||||
Assert.Equal("401", await LoginToken("чужой", "admin-test-pass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminEndpoints_WithoutToken_401()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{ "/api/admin/athletes", "/api/admin/workouts", "/api/admin/metrics?athleteId=1" })
|
||||
{
|
||||
var response = await app.Client.GetAsync(path);
|
||||
Assert.Equal(401, (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminEndpoints_AthleteApiKeyRejected_401()
|
||||
{
|
||||
// 🛡1: атлетский ключ не действует в admin-контуре
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/admin/athletes");
|
||||
request.Headers.Add("X-Api-Key", TestApp.IvanKey);
|
||||
var response = await app.Client.SendAsync(request);
|
||||
Assert.Equal(401, (int)response.StatusCode);
|
||||
}
|
||||
|
||||
// --- spec 02: атлеты ---
|
||||
|
||||
[Fact]
|
||||
public async Task Athletes_CreateKeyOnce_BlockUnblock()
|
||||
{
|
||||
var http = await Client();
|
||||
var name = "adm-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var create = await http.PostAsJsonAsync("/api/admin/athletes", new { name });
|
||||
Assert.Equal(201, (int)create.StatusCode);
|
||||
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var athleteId = created.GetProperty("id").GetInt32();
|
||||
var apiKey = created.GetProperty("apiKey").GetString();
|
||||
Assert.StartsWith("wb-", apiKey);
|
||||
|
||||
// ключ работает на /api/v1 и не возвращается повторно
|
||||
var sync = await app.Client.PostAsync("/api/v1/sync",
|
||||
new StringContent("[]", Encoding.UTF8, "application/json"));
|
||||
var syncRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync")
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
syncRequest.Headers.Add("X-Api-Key", apiKey);
|
||||
var withKey = await app.Client.SendAsync(syncRequest);
|
||||
Assert.True(withKey.IsSuccessStatusCode);
|
||||
|
||||
// блокировка → 401 на sync
|
||||
var block = await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block",
|
||||
new { isBlocked = true });
|
||||
Assert.True(block.IsSuccessStatusCode);
|
||||
var blockedRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync")
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
blockedRequest.Headers.Add("X-Api-Key", apiKey);
|
||||
Assert.Equal(401, (int)(await app.Client.SendAsync(blockedRequest)).StatusCode);
|
||||
|
||||
// смена ключа: старый не работает, новый работает
|
||||
var newKey = await http.PostAsync($"/api/admin/athletes/{athleteId}/key", content: null);
|
||||
var reissued = await newKey.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.NotEqual(apiKey, reissued.GetProperty("apiKey").GetString());
|
||||
var oldKeyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync")
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
oldKeyRequest.Headers.Add("X-Api-Key", apiKey);
|
||||
Assert.Equal(401, (int)(await app.Client.SendAsync(oldKeyRequest)).StatusCode);
|
||||
|
||||
// разблокировка
|
||||
await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block", new { isBlocked = false });
|
||||
var unblockRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync")
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
unblockRequest.Headers.Add("X-Api-Key", reissued.GetProperty("apiKey").GetString());
|
||||
Assert.True((await app.Client.SendAsync(unblockRequest)).IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
// --- spec 03: тренировки + CSV ---
|
||||
|
||||
[Fact]
|
||||
public async Task Workouts_ListCardCsv()
|
||||
{
|
||||
var cs = NewCs();
|
||||
var athleteId = new AthleteRepository(cs).Insert(new Athlete
|
||||
{
|
||||
Name = "adm-w-" + Guid.NewGuid().ToString("N")[..6],
|
||||
ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()),
|
||||
});
|
||||
var workoutId = Guid.NewGuid();
|
||||
// workout_insert не пишет ended_at (errata m02) — дозакрытие Update
|
||||
var workoutsRepo = new WorkoutRepository(cs);
|
||||
workoutsRepo.Insert(athleteId, new Workout
|
||||
{
|
||||
Id = workoutId, AthleteId = athleteId,
|
||||
StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc),
|
||||
Status = "finished", SessionKind = "normal",
|
||||
FactsJson = null, Sets = [], Commands = [],
|
||||
});
|
||||
new WorkoutRepository(cs).Update(athleteId, new Workout
|
||||
{
|
||||
Id = workoutId, AthleteId = athleteId,
|
||||
StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc),
|
||||
EndedAt = new DateTime(2026, 10, 16, 19, 0, 0, DateTimeKind.Utc),
|
||||
AvgHr = 110,
|
||||
Status = "finished", SessionKind = "normal",
|
||||
FactsJson = """{"schema":"workout-facts/1","laps":[{"index":0,"start":"2026-10-16T18:00:00Z","duration_s":300},{"index":1,"start":"2026-10-16T18:10:00Z","duration_s":300}],"records":[{"t_offset_s":10,"hr":100},{"t_offset_s":20,"hr":110},{"t_offset_s":610,"hr":120},{"t_offset_s":700,"hr":85}]}""",
|
||||
Sets = [], Commands = [],
|
||||
});
|
||||
var setRepo = new WorkoutSetRepository(cs);
|
||||
var exerciseId = new ExerciseRepository(cs).Insert(new Exercise
|
||||
{
|
||||
Id = Guid.NewGuid(), AthleteId = athleteId, Name = "жим",
|
||||
GroupId = 1, IsBase = true, IsActive = true, ReplacementIds = [],
|
||||
});
|
||||
setRepo.Insert(new WorkoutSet
|
||||
{
|
||||
Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId,
|
||||
ExerciseId = exerciseId, Seq = 1, WeightKg = 80, Reps = 8,
|
||||
DoneAt = new DateTime(2026, 10, 16, 18, 5, 0, DateTimeKind.Utc),
|
||||
Source = "voice", InPlan = true, Rpe = 7,
|
||||
});
|
||||
new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics
|
||||
{
|
||||
WorkoutId = workoutId, AthleteId = athleteId, Adherence = 100m, Tonnage = 640m,
|
||||
TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]",
|
||||
});
|
||||
|
||||
var http = await Client();
|
||||
|
||||
var list = await http.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/admin/workouts?athleteId={athleteId}");
|
||||
Assert.Equal(1, list.GetProperty("total").GetInt32());
|
||||
var item = list.GetProperty("items")[0];
|
||||
Assert.Equal(60, item.GetProperty("durationMin").GetInt32());
|
||||
Assert.Equal(100m, item.GetProperty("adherence").GetDecimal());
|
||||
|
||||
var card = await http.GetFromJsonAsync<JsonElement>($"/api/admin/workouts/{workoutId}");
|
||||
Assert.Equal(1, card.GetProperty("sets").GetArrayLength());
|
||||
Assert.Equal(2, card.GetProperty("timeline").GetArrayLength());
|
||||
var timeline0 = card.GetProperty("timeline")[0];
|
||||
Assert.Equal(105, timeline0.GetProperty("avgHr").GetInt32()); // (100+110)/2
|
||||
Assert.Equal(110, timeline0.GetProperty("peakHr").GetInt32());
|
||||
Assert.Equal(300, timeline0.GetProperty("restAfterS").GetInt32()); // 18:10 − 18:05
|
||||
Assert.True(card.GetProperty("hrCurve").GetArrayLength() >= 4);
|
||||
|
||||
var csv = await http.GetAsync($"/api/admin/workouts.csv?athleteId={athleteId}");
|
||||
var text = await csv.Content.ReadAsStringAsync();
|
||||
Assert.Equal("text/csv", csv.Content.Headers.ContentType!.MediaType);
|
||||
Assert.StartsWith("\ufeffathlete;date;duration_min;avg_hr;", text);
|
||||
Assert.Contains(";2026-10-16;60;", text, StringComparison.Ordinal);
|
||||
Assert.Contains(";60;110;1;1;100.0", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// --- spec 04: программы ---
|
||||
|
||||
[Fact]
|
||||
public async Task Programs_ActivateAndApplyRules()
|
||||
{
|
||||
var cs = NewCs();
|
||||
var athleteId = new AthleteRepository(cs).Insert(new Athlete
|
||||
{
|
||||
Name = "adm-p-" + Guid.NewGuid().ToString("N")[..6],
|
||||
ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()),
|
||||
});
|
||||
var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs));
|
||||
var user = store.CreateDraft(athleteId, """{"weeks":[{"week":1,"sessions":[]}]}""",
|
||||
"user", "база");
|
||||
store.Activate(athleteId, user.Id);
|
||||
var rules = store.CreateDraft(athleteId,
|
||||
"""{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[]}]}]}""",
|
||||
"rules", "прогрессия");
|
||||
|
||||
var http = await Client();
|
||||
|
||||
var list = await http.GetFromJsonAsync<JsonElement>($"/api/admin/programs?athleteId={athleteId}");
|
||||
Assert.Equal(2, list.GetProperty("items").GetArrayLength());
|
||||
|
||||
// применение rules-предложения → author=admin, active; оригинал не тронут
|
||||
var apply = await http.PostAsync($"/api/admin/programs/{rules.Id}/apply?athleteId={athleteId}",
|
||||
content: null);
|
||||
Assert.True(apply.IsSuccessStatusCode);
|
||||
var applied = await apply.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var appliedVersionId = Guid.Parse(applied.GetProperty("versionId").GetString());
|
||||
Assert.Equal("active", applied.GetProperty("status").GetString());
|
||||
var appliedRow = store.GetVersion(athleteId, appliedVersionId)!;
|
||||
Assert.Equal("admin", appliedRow.Author);
|
||||
Assert.Equal("draft", store.GetVersion(athleteId, rules.Id)!.Status); // 🛡4
|
||||
Assert.Equal(user.Version + 2, appliedRow.Version);
|
||||
|
||||
// применить не-rules — 400
|
||||
var userDraft = store.CreateDraft(athleteId, """{"weeks":[]}""", "user", "v3");
|
||||
var badApply = await http.PostAsync($"/api/admin/programs/{userDraft.Id}/apply?athleteId={athleteId}",
|
||||
content: null);
|
||||
Assert.Equal(400, (int)badApply.StatusCode);
|
||||
|
||||
// активация активной — 400
|
||||
var reActivate = await http.PostAsync(
|
||||
$"/api/admin/programs/{appliedVersionId}/activate?athleteId={athleteId}", content: null);
|
||||
Assert.Equal(400, (int)reActivate.StatusCode);
|
||||
}
|
||||
|
||||
// --- spec 05: метрики + план/факт ---
|
||||
|
||||
[Fact]
|
||||
public async Task Metrics_And_PlanSummary()
|
||||
{
|
||||
var cs = NewCs();
|
||||
var athleteId = new AthleteRepository(cs).Insert(new Athlete
|
||||
{
|
||||
Name = "adm-m-" + Guid.NewGuid().ToString("N")[..6],
|
||||
ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()),
|
||||
});
|
||||
var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs));
|
||||
// якорь сводки — понедельник недели создания версии: даты от сегодня
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7));
|
||||
var version = store.CreateDraft(athleteId,
|
||||
"""{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[{"exercise_id":"жим","sets":[{"weight_kg":80,"reps":8}]}]}]}]}""",
|
||||
"user", "тест");
|
||||
store.Activate(athleteId, version.Id);
|
||||
|
||||
var workoutsRepo = new WorkoutRepository(cs);
|
||||
var mondayWorkoutId = Guid.NewGuid();
|
||||
workoutsRepo.Insert(athleteId, new Workout
|
||||
{
|
||||
Id = mondayWorkoutId, AthleteId = athleteId,
|
||||
StartedAt = monday.ToDateTimeAt(18),
|
||||
Status = "finished", SessionKind = "normal", Sets = [], Commands = [],
|
||||
});
|
||||
workoutsRepo.Insert(athleteId, new Workout
|
||||
{
|
||||
Id = Guid.NewGuid(), AthleteId = athleteId,
|
||||
StartedAt = monday.AddDays(2).ToDateTimeAt(18), // ср — не плановый день
|
||||
Status = "finished", SessionKind = "normal", Sets = [], Commands = [],
|
||||
});
|
||||
new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics
|
||||
{
|
||||
WorkoutId = mondayWorkoutId, AthleteId = athleteId, Adherence = 90m, Tonnage = 1000m,
|
||||
TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]",
|
||||
});
|
||||
|
||||
var http = await Client();
|
||||
|
||||
var from = monday.AddDays(-3).ToString("yyyy-MM-dd");
|
||||
var to = monday.AddDays(6).ToString("yyyy-MM-dd");
|
||||
var metrics = await http.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/admin/metrics?athleteId={athleteId}&dateFrom={from}&dateTo={to}");
|
||||
Assert.Equal(1, metrics.GetProperty("workouts").GetArrayLength());
|
||||
Assert.Equal(1, metrics.GetProperty("weekly").GetArrayLength());
|
||||
var weekly = metrics.GetProperty("weekly")[0];
|
||||
Assert.Equal(1000m, weekly.GetProperty("tonnage").GetDecimal());
|
||||
Assert.Equal(90m, weekly.GetProperty("avgAdherence").GetDecimal());
|
||||
|
||||
var plan = await http.GetFromJsonAsync<JsonElement>($"/api/admin/plan?athleteId={athleteId}");
|
||||
Assert.Equal(version.Version, plan.GetProperty("activeVersion").GetInt32());
|
||||
var week1 = plan.GetProperty("weeks")[0];
|
||||
Assert.Equal(1, week1.GetProperty("planned").GetInt32());
|
||||
Assert.Equal(1, week1.GetProperty("performed").GetInt32()); // только пн; ср — не плановый день
|
||||
Assert.Equal(0, week1.GetProperty("missed").GetInt32());
|
||||
Assert.Equal(90m, week1.GetProperty("avgAdherence").GetDecimal());
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@
|
|||
{ "name": "Test Анна", "apiKey": "test-key-anna-0003" }
|
||||
]
|
||||
},
|
||||
"Admin": {
|
||||
"Login": "test-admin",
|
||||
"PasswordHash": "pbkdf2$100000$AAECAwQFBgcICQoLDA0ODw==$Gq5LDyI8L0fc+2TIN8q5dyn/GgAvxyHd8vn6BFz5rjw="
|
||||
},
|
||||
"Db": {
|
||||
"ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,5 +28,59 @@ public class RouteConfig
|
|||
routeBuilder.AddController<ProgramController>()
|
||||
.ActionAction("api/v1/programs/active", x => x.Active)
|
||||
.NeedHttpGet();
|
||||
|
||||
// admin-контур (m08): Bearer, отдельный от X-Api-Key.
|
||||
// по одному блоку на действие: NeedHttpPost_All нельзя чейнить
|
||||
// на несколько роутов (RouteFluent — дубль ключа httpMethod)
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAuthController>()
|
||||
.ActionAction("api/admin/login", x => x.Login)
|
||||
.NeedHttpPost_All();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAuthController>()
|
||||
.ActionAction("api/admin/logout", x => x.Logout)
|
||||
.NeedHttpPost_All();
|
||||
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAthletesController>()
|
||||
.ActionAction("api/admin/athletes", x => x.List)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAthletesController>()
|
||||
.ActionAction("api/admin/athletes", x => x.Create)
|
||||
.NeedHttpPost_All();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAthletesController>()
|
||||
.ActionAction<int>("api/admin/athletes/{id}/key", x => x.NewKey)
|
||||
.NeedHttpPost_All();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminAthletesController>()
|
||||
.ActionAction<int>("api/admin/athletes/{id}/block", x => x.Block)
|
||||
.NeedHttpPost_All();
|
||||
|
||||
routeBuilder.AddController<Controllers.Admin.AdminWorkoutsController>()
|
||||
.ActionAction<int?, string?, string?, int?, int?>(
|
||||
"api/admin/workouts", x => x.List)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminWorkoutsController>()
|
||||
.ActionAction<string>("api/admin/workouts/{id}", x => x.Get)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminWorkoutsController>()
|
||||
.ActionAction<int?, string?, string?>("api/admin/workouts.csv", x => x.Csv)
|
||||
.NeedHttpGet();
|
||||
|
||||
routeBuilder.AddController<Controllers.Admin.AdminProgramsController>()
|
||||
.ActionAction<int>("api/admin/programs", x => x.List)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminProgramsController>()
|
||||
.ActionAction<string, int>("api/admin/programs/{versionId}", x => x.Get)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminProgramsController>()
|
||||
.ActionAction<string, int>("api/admin/programs/{versionId}/activate", x => x.Activate)
|
||||
.NeedHttpPost_All();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminProgramsController>()
|
||||
.ActionAction<string, int>("api/admin/programs/{versionId}/apply", x => x.Apply)
|
||||
.NeedHttpPost_All();
|
||||
|
||||
routeBuilder.AddController<Controllers.Admin.AdminMetricsController>()
|
||||
.ActionAction<int, string?, string?>("api/admin/metrics", x => x.Metrics)
|
||||
.NeedHttpGet();
|
||||
routeBuilder.AddController<Controllers.Admin.AdminMetricsController>()
|
||||
.ActionAction<int>("api/admin/plan", x => x.Plan)
|
||||
.NeedHttpGet();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ public class ServerConfig
|
|||
public string ImportArchiveDir { get; set; } =
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "import_done");
|
||||
|
||||
/// <summary>Учётка владельца консоли (m08); Login пуст — консоль отключена.</summary>
|
||||
public string AdminLogin { get; set; } = string.Empty;
|
||||
public string AdminPasswordHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Учётка Garmin Connect владельца (--mode fetch, m07).</summary>
|
||||
public string GarminLogin { get; set; } = string.Empty;
|
||||
public string GarminPassword { get; set; } = string.Empty;
|
||||
|
|
@ -77,6 +81,11 @@ public class ServerConfig
|
|||
}
|
||||
}
|
||||
}
|
||||
if (root.TryGetProperty("Admin", out var admin) && admin.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (admin.TryGetProperty("Login", out var al)) result.AdminLogin = al.GetString() ?? "";
|
||||
if (admin.TryGetProperty("PasswordHash", out var aph)) result.AdminPasswordHash = aph.GetString() ?? "";
|
||||
}
|
||||
if (root.TryGetProperty("Db", out var db))
|
||||
{
|
||||
if (db.TryGetProperty("ConnectionString", out var cs))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>Атлеты (spec 02 m08): список/создание/смена ключа/блок.</summary>
|
||||
public class AdminAthletesController(AdminAuthService auth, IAthleteRepository athletes)
|
||||
: AdminControllerBase(auth)
|
||||
{
|
||||
public ActionResult List()
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var items = athletes.Select(null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id,
|
||||
a.Name,
|
||||
a.IsBlocked,
|
||||
createdAt = a.CreatedAt,
|
||||
});
|
||||
return new JsonResult(new { items });
|
||||
}
|
||||
|
||||
public ActionResult Create()
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var name = ReadString("name");
|
||||
if (string.IsNullOrWhiteSpace(name)) return ErrorJson(400, "name пуст");
|
||||
|
||||
var (key, hash) = NewKey();
|
||||
var id = athletes.Insert(new Athlete { Name = name, ApiKeyHash = hash });
|
||||
// ключ возвращается один раз (🛡2): дальше в БД только хэш
|
||||
Response.StatusCode = 201;
|
||||
return new JsonResult(new { id, apiKey = key });
|
||||
}
|
||||
|
||||
public ActionResult NewKey(int id)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var athlete = athletes.Get(id);
|
||||
if (athlete == null) return ErrorJson(404, "not_found");
|
||||
|
||||
var (key, hash) = NewKey();
|
||||
athletes.Update(new Athlete
|
||||
{
|
||||
Id = id, Name = athlete.Name, ApiKeyHash = hash, IsBlocked = athlete.IsBlocked,
|
||||
});
|
||||
return new JsonResult(new { apiKey = key });
|
||||
}
|
||||
|
||||
public ActionResult Block(int id)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var athlete = athletes.Get(id);
|
||||
if (athlete == null) return ErrorJson(404, "not_found");
|
||||
var blocked = ReadBool("isBlocked") ?? !athlete.IsBlocked;
|
||||
athletes.Update(new Athlete
|
||||
{
|
||||
Id = id, Name = athlete.Name, ApiKeyHash = athlete.ApiKeyHash, IsBlocked = blocked,
|
||||
});
|
||||
return new JsonResult(new { id, isBlocked = blocked });
|
||||
}
|
||||
|
||||
private static (string Key, string Hash) NewKey()
|
||||
{
|
||||
var key = "wb-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(12)).ToLowerInvariant();
|
||||
return (key, Services.ApiKeyHash.Of(key));
|
||||
}
|
||||
|
||||
private string? ReadString(string property)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = reader.ReadToEndAsync().GetAwaiter().GetResult();
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
return doc.RootElement.TryGetProperty(property, out var value) ? value.GetString() : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool? ReadBool(string property)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = reader.ReadToEndAsync().GetAwaiter().GetResult();
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
return doc.RootElement.TryGetProperty(property, out var value) &&
|
||||
value.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False
|
||||
? value.GetBoolean()
|
||||
: null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>POST /api/admin/login, POST /api/admin/logout (spec 01).</summary>
|
||||
public class AdminAuthController(AdminAuthService auth) : Controller
|
||||
{
|
||||
public ActionResult Login()
|
||||
{
|
||||
var body = ReadJson();
|
||||
var login = body?.TryGetProperty("login", out var l) == true ? l.GetString() : null;
|
||||
var password = body?.TryGetProperty("password", out var p) == true ? p.GetString() : null;
|
||||
var token = login == null || password == null ? null : auth.Login(login, password);
|
||||
if (token == null)
|
||||
{
|
||||
Response.StatusCode = 401;
|
||||
return new JsonResult(new { error = "unauthorized" });
|
||||
}
|
||||
return new JsonResult(new { token });
|
||||
}
|
||||
|
||||
public ActionResult Logout()
|
||||
{
|
||||
var header = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (header?.StartsWith("Bearer ") == true)
|
||||
auth.Logout(header["Bearer ".Length..]);
|
||||
return new JsonResult(new { ok = true });
|
||||
}
|
||||
|
||||
private System.Text.Json.JsonElement? ReadJson()
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = reader.ReadToEndAsync().GetAwaiter().GetResult();
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonDocument.Parse(json).RootElement.Clone();
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// База admin-контроллеров (spec 01 §3): Bearer-токен обязателен,
|
||||
/// без/невалидный → 401 {"error":"unauthorized"}; атлетские
|
||||
/// X-Api-Key в этом контуре не действуют (🛡1 — middleware их сюда
|
||||
/// не пускает).
|
||||
/// </summary>
|
||||
public abstract class AdminControllerBase(AdminAuthService auth) : Controller
|
||||
{
|
||||
protected readonly AdminAuthService Auth = auth;
|
||||
|
||||
/// <summary>401, если токен не прошёл; иначе false.</summary>
|
||||
protected bool Denied()
|
||||
{
|
||||
var header = Request.Headers.Authorization.FirstOrDefault();
|
||||
var token = header?.StartsWith("Bearer ", StringComparison.Ordinal) == true
|
||||
? header["Bearer ".Length..]
|
||||
: null;
|
||||
return !Auth.Validate(token);
|
||||
}
|
||||
|
||||
protected ActionResult UnauthorizedJson() =>
|
||||
ErrorJson(401, "unauthorized");
|
||||
|
||||
protected ActionResult ErrorJson(int code, string message)
|
||||
{
|
||||
Response.StatusCode = code;
|
||||
return new JsonResult(new { error = message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>Метрики (Ф-9.4) и сводка план/факт (Ф-9.5) — только
|
||||
/// чтение (spec 05 m08).</summary>
|
||||
public class AdminMetricsController(
|
||||
AdminAuthService auth,
|
||||
IWorkoutRepository workouts,
|
||||
IWorkoutMetricsRepository metrics,
|
||||
PlanSummaryService plan) : AdminControllerBase(auth)
|
||||
{
|
||||
public ActionResult Metrics(int athleteId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
DateOnly? from = dateFrom != null && DateOnly.TryParse(dateFrom, out var f) ? f : null;
|
||||
DateOnly? to = dateTo != null && DateOnly.TryParse(dateTo, out var t) ? t : null;
|
||||
|
||||
var rows = metrics.Select(new WorkoutMetricsFilter
|
||||
{
|
||||
AthleteId = athleteId, DateFrom = from, DateTo = to,
|
||||
}).ToList();
|
||||
var byDate = new Dictionary<Guid, DateTime>();
|
||||
foreach (var workout in workouts.Select(new WorkoutFilter
|
||||
{ AthleteId = athleteId, DateFrom = from, DateTo = to }))
|
||||
byDate[workout.Id] = workout.StartedAt;
|
||||
|
||||
var workoutRows = rows
|
||||
.OrderByDescending(m => byDate.GetValueOrDefault(m.WorkoutId))
|
||||
.Select(m => new
|
||||
{
|
||||
m.WorkoutId,
|
||||
date = byDate.TryGetValue(m.WorkoutId, out var started)
|
||||
? DateOnly.FromDateTime(started).ToString("yyyy-MM-dd") : null,
|
||||
m.Adherence,
|
||||
m.Tonnage,
|
||||
tonnageByGroup = Json(m.TonnageByGroup),
|
||||
e1pmByExercise = Json(m.E1pmByExercise),
|
||||
redFlags = Json(m.RedFlags),
|
||||
}).ToList();
|
||||
|
||||
// weekly: ISO-неделя даты тренировки
|
||||
var weekly = workoutRows
|
||||
.Where(r => r.date != null)
|
||||
.GroupBy(r => MondayOf(DateOnly.Parse(r.date!)))
|
||||
.Select(g => new
|
||||
{
|
||||
week = g.Key.ToString("yyyy-MM-dd"),
|
||||
workouts = g.Count(),
|
||||
avgAdherence = Math.Round(g.Average(r => r.Adherence), 1, MidpointRounding.AwayFromZero),
|
||||
tonnage = Math.Round(g.Sum(r => r.Tonnage), 1, MidpointRounding.AwayFromZero),
|
||||
}).OrderBy(w => w.week).ToList();
|
||||
|
||||
return new JsonResult(new { workouts = workoutRows, weekly });
|
||||
}
|
||||
|
||||
public ActionResult Plan(int athleteId)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
return new JsonResult(plan.Summarize(athleteId));
|
||||
}
|
||||
|
||||
private static object? Json(string? source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source)) return null;
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<object>(source);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateOnly MondayOf(DateOnly date) =>
|
||||
date.AddDays(-(((int)date.DayOfWeek + 6) % 7));
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Core.Programs;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Программы (spec 04 m08): версии, активация draft, применение
|
||||
/// rules-предложения через author=admin-копию (🛡4 — оригинал не
|
||||
/// меняется, Ф-9.3).
|
||||
/// </summary>
|
||||
public class AdminProgramsController(
|
||||
AdminAuthService auth,
|
||||
IProgramRepository programs,
|
||||
ProgramStore store) : AdminControllerBase(auth)
|
||||
{
|
||||
public ActionResult List(int athleteId)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var versions = store.ListVersions(athleteId);
|
||||
return new JsonResult(new
|
||||
{
|
||||
items = versions.Select(v => new
|
||||
{
|
||||
versionId = v.Id,
|
||||
v.Version,
|
||||
v.Status,
|
||||
v.Author,
|
||||
v.BasedOnVersion,
|
||||
v.Rationale,
|
||||
v.CreatedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public ActionResult Get(string versionId, int athleteId)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid");
|
||||
var version = store.GetVersion(athleteId, id);
|
||||
if (version == null) return ErrorJson(404, "not_found");
|
||||
return new JsonResult(new
|
||||
{
|
||||
versionId = version.Id,
|
||||
version.Version,
|
||||
version.Status,
|
||||
version.Author,
|
||||
version.BasedOnVersion,
|
||||
version.Body,
|
||||
version.Rationale,
|
||||
version.CreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
public ActionResult Activate(string versionId, int athleteId)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid");
|
||||
try
|
||||
{
|
||||
store.Activate(athleteId, id);
|
||||
}
|
||||
catch (ProgramStoreException e)
|
||||
{
|
||||
return ErrorJson(400, e.Message);
|
||||
}
|
||||
var version = store.GetVersion(athleteId, id)!;
|
||||
return new JsonResult(new { versionId = id, version.Version, status = version.Status });
|
||||
}
|
||||
|
||||
/// <summary>Применение предложения правил (Ф-9.3): author=admin-копия
|
||||
/// тела rules-draft → активация; оригинал остаётся draft (🛡4).</summary>
|
||||
public ActionResult Apply(string versionId, int athleteId)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid");
|
||||
var source = store.GetVersion(athleteId, id);
|
||||
if (source == null) return ErrorJson(404, "not_found");
|
||||
if (source.Author != ProgramVersionAuthors.Rules || source.Status != ProgramVersionStatuses.Draft)
|
||||
return ErrorJson(400, "применять можно только draft-предложение правил (author=rules)");
|
||||
|
||||
try
|
||||
{
|
||||
var applied = store.CreateDraft(athleteId, source.Body, ProgramVersionAuthors.Admin,
|
||||
$"{source.Rationale} (применено из консоли)".TrimStart());
|
||||
store.Activate(athleteId, applied.Id);
|
||||
return new JsonResult(new
|
||||
{
|
||||
versionId = applied.Id,
|
||||
version = applied.Version,
|
||||
status = store.GetVersion(athleteId, applied.Id)!.Status,
|
||||
});
|
||||
}
|
||||
catch (ProgramStoreException e)
|
||||
{
|
||||
return ErrorJson(400, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
namespace Server.WatchBody.Web.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Тренировки всех атлетов (spec 03 m08): список с фильтрами,
|
||||
/// карточка (план/факт + facts-таймлайн), CSV выборки (КУ-4).
|
||||
/// workout_select требует одного атлета — выборка «всех» собирается
|
||||
/// обходом атлетов (v1: атлетов немного). 🛡3: только чтение.
|
||||
/// </summary>
|
||||
public class AdminWorkoutsController(
|
||||
AdminAuthService auth,
|
||||
IAthleteRepository athletesRepo,
|
||||
IWorkoutRepository workouts,
|
||||
IWorkoutMetricsRepository metrics,
|
||||
IExerciseRepository exercises,
|
||||
Core.Programs.ProgramStore programs) : AdminControllerBase(auth)
|
||||
{
|
||||
private const int CsvLimit = 10_000;
|
||||
private static readonly string[] DayNames =
|
||||
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
||||
|
||||
public ActionResult List(int? athleteId, string? dateFrom, string? dateTo, int? limit, int? offset)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var take = limit ?? 50;
|
||||
var skip = offset ?? 0;
|
||||
if (take is < 1 or > 100) return ErrorJson(400, "limit 1–100");
|
||||
if (skip < 0) return ErrorJson(400, "offset >= 0");
|
||||
|
||||
var all = SelectAll(athleteId, dateFrom, dateTo, out var names);
|
||||
var page = all.OrderByDescending(w => w.StartedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.Select(w => Card(w, names, withSets: false)).ToList();
|
||||
return new JsonResult(new { total = all.Count, items = page });
|
||||
}
|
||||
|
||||
public ActionResult Get(string id)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
if (!Guid.TryParse(id, out var workoutId)) return ErrorJson(400, "id не uuid");
|
||||
Workout? found = null;
|
||||
Dictionary<int, string> names = new();
|
||||
foreach (var athlete in athletesRepo.Select(null))
|
||||
{
|
||||
var candidate = workouts.Get(athlete.Id, workoutId);
|
||||
if (candidate == null) continue;
|
||||
found = candidate;
|
||||
names[athlete.Id] = athlete.Name;
|
||||
break;
|
||||
}
|
||||
if (found == null) return ErrorJson(404, "not_found");
|
||||
|
||||
var card = Card(found, names, withSets: true);
|
||||
var (timeline, curve) = FactsTimeline.Parse(found.FactsJson, 0);
|
||||
card["timeline"] = timeline;
|
||||
card["hrCurve"] = curve;
|
||||
return new JsonResult(card);
|
||||
}
|
||||
|
||||
public ActionResult Csv(int? athleteId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
if (Denied()) return UnauthorizedJson();
|
||||
var all = SelectAll(athleteId, dateFrom, dateTo, out var names);
|
||||
|
||||
// tech-req 2.4: ';' + UTF-8 BOM (Excel), заголовок первой строкой
|
||||
var csv = new StringBuilder();
|
||||
csv.AppendLine("athlete;date;duration_min;avg_hr;sets;exercises;adherence_pct");
|
||||
foreach (var workout in all.OrderByDescending(w => w.StartedAt).Take(CsvLimit))
|
||||
csv.AppendLine(CsvRow(workout, names));
|
||||
var bytes = Encoding.UTF8.GetPreamble()
|
||||
.Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray();
|
||||
return File(bytes, "text/csv; charset=utf-8", "workouts.csv");
|
||||
}
|
||||
|
||||
// ---------- вспомогательные ----------
|
||||
|
||||
private List<Workout> SelectAll(int? athleteId, string? dateFrom, string? dateTo,
|
||||
out Dictionary<int, string> names)
|
||||
{
|
||||
names = athletesRepo.Select(null).ToDictionary(a => a.Id, a => a.Name);
|
||||
var from = ParseDate(dateFrom);
|
||||
var to = ParseDate(dateTo);
|
||||
var result = new List<Workout>();
|
||||
foreach (var athlete in names.Keys)
|
||||
{
|
||||
if (athleteId != null && athlete != athleteId.Value) continue;
|
||||
result.AddRange(workouts.Select(new WorkoutFilter
|
||||
{
|
||||
AthleteId = athlete, DateFrom = from, DateTo = to,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? text) =>
|
||||
text != null && DateOnly.TryParse(text, out var date) ? date : null;
|
||||
|
||||
private Dictionary<string, object?> Card(Workout w, Dictionary<int, string> names, bool withSets)
|
||||
{
|
||||
var durationMin = w.EndedAt != null
|
||||
? (int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero)
|
||||
: (int?)null;
|
||||
var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id })
|
||||
.FirstOrDefault();
|
||||
var sets = w.Sets.ToList();
|
||||
var card = new Dictionary<string, object?>
|
||||
{
|
||||
["workoutId"] = w.Id,
|
||||
["athleteId"] = w.AthleteId,
|
||||
["athleteName"] = names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"),
|
||||
["startedAt"] = w.StartedAt,
|
||||
["endedAt"] = w.EndedAt,
|
||||
["durationMin"] = durationMin,
|
||||
["status"] = w.Status,
|
||||
["sessionKind"] = w.SessionKind,
|
||||
["avgHr"] = w.AvgHr,
|
||||
["setsCount"] = sets.Count,
|
||||
["exercisesCount"] = sets.Where(s => s.ExerciseId != null)
|
||||
.Select(s => s.ExerciseId!.Value).Distinct().Count(),
|
||||
["adherence"] = metric?.Adherence,
|
||||
};
|
||||
if (!withSets) return card;
|
||||
|
||||
var exNames = exercises.Select(new ExerciseFilter { AthleteId = w.AthleteId })
|
||||
.ToDictionary(e => e.Id, e => e.Name);
|
||||
card["sets"] = sets.OrderBy(s => s.Seq).Select(s => new
|
||||
{
|
||||
s.Seq,
|
||||
exerciseName = s.ExerciseId != null && exNames.TryGetValue(s.ExerciseId.Value, out var n) ? n : null,
|
||||
s.WeightKg,
|
||||
s.Reps,
|
||||
s.InPlan,
|
||||
s.Rpe,
|
||||
s.KneePain,
|
||||
}).ToList();
|
||||
card["planFact"] = PlanFact(w, exNames);
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>План/факт по упражнениям (spec 03 §2): план — первая
|
||||
/// сессия дня недели активной версии (v1 — без ротации недель).</summary>
|
||||
private object PlanFact(Workout w, Dictionary<Guid, string> exNames)
|
||||
{
|
||||
var active = programs.GetActive(w.AthleteId);
|
||||
var date = DateOnly.FromDateTime(w.StartedAt);
|
||||
var day = DayNames[((int)date.DayOfWeek + 6) % 7];
|
||||
var plan = new List<object>();
|
||||
if (active == null || string.IsNullOrWhiteSpace(active.Body))
|
||||
return new { day, plan };
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(active.Body);
|
||||
foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray())
|
||||
foreach (var session in week.GetProperty("sessions").EnumerateArray())
|
||||
{
|
||||
if (!session.TryGetProperty("day", out var d) ||
|
||||
!string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
foreach (var exercise in session.GetProperty("exercises").EnumerateArray())
|
||||
plan.Add(PlanFactExercise(exercise, w, exNames));
|
||||
return new { day, plan }; // первая сессия дня
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException)
|
||||
{
|
||||
// кривой body — только факт
|
||||
}
|
||||
return new { day, plan };
|
||||
}
|
||||
|
||||
private static object PlanFactExercise(System.Text.Json.JsonElement exercise, Workout w,
|
||||
Dictionary<Guid, string> exNames)
|
||||
{
|
||||
var name = exercise.TryGetProperty("exercise_id", out var e) ? e.GetString() : "?";
|
||||
var setsNode = exercise.TryGetProperty("sets", out var s) ? s : default;
|
||||
var hasSets = setsNode.ValueKind == System.Text.Json.JsonValueKind.Array;
|
||||
var first = hasSets && setsNode.GetArrayLength() > 0 ? setsNode[0] : default;
|
||||
var weight = first.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||
first.TryGetProperty("weight_kg", out var wNode) &&
|
||||
wNode.ValueKind == System.Text.Json.JsonValueKind.Number
|
||||
? wNode.GetDecimal() : (decimal?)null;
|
||||
var reps = first.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||
first.TryGetProperty("reps", out var rNode) &&
|
||||
rNode.ValueKind == System.Text.Json.JsonValueKind.Number
|
||||
? rNode.GetInt32() : (int?)null;
|
||||
var factSets = w.Sets.Where(fs =>
|
||||
fs.ExerciseId != null && exNames.GetValueOrDefault(fs.ExerciseId.Value) == name)
|
||||
.ToList();
|
||||
return new
|
||||
{
|
||||
exercise = name,
|
||||
plannedSets = hasSets ? setsNode.GetArrayLength() : 0,
|
||||
plannedWeight = weight,
|
||||
plannedReps = reps,
|
||||
factSets = factSets.Count,
|
||||
factWeight = factSets.Count > 0 ? factSets.Max(fs => fs.WeightKg) : (decimal?)null,
|
||||
factReps = factSets.Count > 0 ? factSets.Max(fs => fs.Reps) : (int?)null,
|
||||
};
|
||||
}
|
||||
|
||||
private string CsvRow(Workout w, Dictionary<int, string> names)
|
||||
{
|
||||
var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id })
|
||||
.FirstOrDefault();
|
||||
var sets = w.Sets.ToList();
|
||||
var invariant = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var durationMin = w.EndedAt != null
|
||||
? ((int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero))
|
||||
.ToString(invariant) : "";
|
||||
return string.Join(';',
|
||||
names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"),
|
||||
DateOnly.FromDateTime(w.StartedAt).ToString("yyyy-MM-dd"),
|
||||
durationMin,
|
||||
w.AvgHr?.ToString(invariant) ?? "",
|
||||
sets.Count.ToString(invariant),
|
||||
sets.Where(s => s.ExerciseId != null).Select(s => s.ExerciseId!.Value).Distinct().Count()
|
||||
.ToString(invariant),
|
||||
metric != null ? metric.Adherence.ToString("0.0", invariant) : "");
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,10 @@ public class ApiKeyMiddleware(RequestDelegate next)
|
|||
|
||||
public async Task InvokeAsync(HttpContext context, IAthleteRepository athletes)
|
||||
{
|
||||
// /health без ключа — только GET (З-42 п. 8): прочие методы идут
|
||||
// в аутентификацию и получают 401, а не деградацию 404/405
|
||||
if (context.Request.Path == "/health" &&
|
||||
HttpMethods.IsGet(context.Request.Method))
|
||||
// ключ атлета охраняет только телефонный контур /api/v1/*:
|
||||
// /api/admin — Bearer-контур владельца (m08, 🛡1), статика
|
||||
// консоли (/admin) и /health — без ключа
|
||||
if (!context.Request.Path.StartsWithSegments("/api/v1"))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace Server.WatchBody.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Аутентификация владельца консоли (spec 01 m08): PBKDF2-проверка
|
||||
/// Admin.PasswordHash → in-memory Bearer-сессии (TTL 8 ч; рестарт
|
||||
/// сервера сбрасывает). 🛡 Токены/пароли не логируются и не
|
||||
/// возвращаются; Admin.Login пуст — консоль отключена.
|
||||
/// </summary>
|
||||
public class AdminAuthService(string login, string passwordHash)
|
||||
{
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromHours(8);
|
||||
private readonly Dictionary<string, DateTime> _sessions = new();
|
||||
|
||||
public bool Enabled => login.Length > 0 && passwordHash.StartsWith("pbkdf2$", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Вход → токен (base64url) или null.</summary>
|
||||
public string? Login(string candidateLogin, string candidatePassword)
|
||||
{
|
||||
if (!Enabled ||
|
||||
!FixedTimeEquals(candidateLogin, login) ||
|
||||
!VerifyPassword(candidatePassword, passwordHash))
|
||||
return null;
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
lock (_sessions)
|
||||
{
|
||||
_sessions[token] = DateTime.UtcNow.Add(Ttl);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
public bool Validate(string? token)
|
||||
{
|
||||
if (token == null || token.Length == 0) return false;
|
||||
lock (_sessions)
|
||||
{
|
||||
if (!_sessions.TryGetValue(token, out var expiresAt)) return false;
|
||||
if (expiresAt < DateTime.UtcNow)
|
||||
{
|
||||
_sessions.Remove(token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Logout(string token)
|
||||
{
|
||||
lock (_sessions) _sessions.Remove(token);
|
||||
}
|
||||
|
||||
internal static bool VerifyPassword(string password, string stored)
|
||||
{
|
||||
// pbkdf2$100000$<salt_b64>$<hash_b64>
|
||||
var parts = stored.Split('$');
|
||||
if (parts.Length != 4 || parts[0] != "pbkdf2" ||
|
||||
!int.TryParse(parts[1], out var iterations) || iterations < 1)
|
||||
return false;
|
||||
byte[] salt, expected;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(parts[2]);
|
||||
expected = Convert.FromBase64String(parts[3]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
|
||||
/// <summary>Хэш пароля в формате конфига (тесты; владельцу —
|
||||
/// python-однострочник из spec 01).</summary>
|
||||
public static string HashPassword(string password, byte[]? salt = null)
|
||||
{
|
||||
salt ??= RandomNumberGenerator.GetBytes(16);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, 32);
|
||||
return $"pbkdf2$100000${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
var bytesA = System.Text.Encoding.UTF8.GetBytes(a);
|
||||
var bytesB = System.Text.Encoding.UTF8.GetBytes(b);
|
||||
return bytesA.Length == bytesB.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(bytesA, bytesB);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Server.WatchBody.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Таймлайн тренировки из facts_json (spec 03 §2 m08): по Lap —
|
||||
/// длительность/avg/peak пульс/отдых; пульсограмма records с
|
||||
/// прореживанием до 600 точек.
|
||||
/// </summary>
|
||||
public static class FactsTimeline
|
||||
{
|
||||
public static (List<Dictionary<string, object?>> Timeline, List<Dictionary<string, int>> Curve) Parse(
|
||||
string? factsJson, int sessionDurationS)
|
||||
{
|
||||
var timeline = new List<Dictionary<string, object?>>();
|
||||
var curve = new List<Dictionary<string, int>>();
|
||||
if (string.IsNullOrWhiteSpace(factsJson)) return (timeline, curve);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(factsJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var records = new List<(int t, int hr)>();
|
||||
if (root.TryGetProperty("records", out var recordNodes))
|
||||
foreach (var r in recordNodes.EnumerateArray())
|
||||
if (r.TryGetProperty("t_offset_s", out var t) && r.TryGetProperty("hr", out var hr))
|
||||
records.Add((t.GetInt32(), hr.GetInt32()));
|
||||
|
||||
// пульсограмма: не более 600 точек
|
||||
if (records.Count > 0)
|
||||
{
|
||||
var step = Math.Max(1, (int)Math.Ceiling(records.Count / 600.0));
|
||||
for (var i = 0; i < records.Count; i += step)
|
||||
curve.Add(new Dictionary<string, int> { ["t"] = records[i].t, ["hr"] = records[i].hr });
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("laps", out var lapNodes)) return (timeline, curve);
|
||||
var laps = new List<(DateTime start, int durationS)>();
|
||||
foreach (var l in lapNodes.EnumerateArray())
|
||||
if (l.TryGetProperty("start", out var s) && l.TryGetProperty("duration_s", out var d) &&
|
||||
DateTime.TryParse(s.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal |
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal, out var start))
|
||||
laps.Add((start, d.GetInt32()));
|
||||
var sessionStart = laps.Count > 0 ? laps.Min(l => l.start)
|
||||
: records.Count > 0 ? DateTime.MinValue : DateTime.MinValue;
|
||||
|
||||
for (var i = 0; i < laps.Count; i++)
|
||||
{
|
||||
var offset = (int)(laps[i].start - sessionStart).TotalSeconds;
|
||||
var end = offset + laps[i].durationS;
|
||||
var nextStart = i + 1 < laps.Count ? (int)(laps[i + 1].start - sessionStart).TotalSeconds : end;
|
||||
var inLap = records.Where(r => r.t >= offset && r.t <= end).Select(r => r.hr).ToList();
|
||||
timeline.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["index"] = i,
|
||||
["startOffsetS"] = offset,
|
||||
["durationS"] = laps[i].durationS,
|
||||
["avgHr"] = inLap.Count > 0 ? (int)Math.Round(inLap.Average(), MidpointRounding.AwayFromZero) : null,
|
||||
["peakHr"] = inLap.Count > 0 ? inLap.Max() : null,
|
||||
["restAfterS"] = Math.Max(0, nextStart - end),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// битые факты — пустой таймлайн
|
||||
}
|
||||
return (timeline, curve);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using System.Text.Json;
|
||||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||||
using Server.WatchBody.Core.Programs;
|
||||
|
||||
namespace Server.WatchBody.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка план/факт по активной версии (spec 05 §2 m08): недели от
|
||||
/// понедельника недели создания версии; performed — тренировки в
|
||||
/// плановые дни недели; missed = planned − performed.
|
||||
/// </summary>
|
||||
public class PlanSummaryService(
|
||||
IWorkoutRepository workouts,
|
||||
IWorkoutMetricsRepository metrics,
|
||||
ProgramStore programs)
|
||||
{
|
||||
private static readonly string[] DayNames =
|
||||
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
||||
|
||||
public object Summarize(int athleteId)
|
||||
{
|
||||
var active = programs.GetActive(athleteId);
|
||||
if (active == null || string.IsNullOrWhiteSpace(active.Body))
|
||||
return new { activeVersion = (int?)null, weeks = Array.Empty<object>() };
|
||||
|
||||
var weeks = ParseWeeks(active.Body);
|
||||
var versionMonday = MondayOf(DateOnly.FromDateTime(active.CreatedAt));
|
||||
|
||||
var rows = new List<object>();
|
||||
for (var i = 0; i < weeks.Count; i++)
|
||||
{
|
||||
var from = versionMonday.AddDays(7 * i);
|
||||
var to = from.AddDays(6);
|
||||
var plannedDays = weeks[i].ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var planned = weeks[i].Count;
|
||||
if (planned == 0) continue;
|
||||
|
||||
var inWeek = workouts.Select(new WorkoutFilter
|
||||
{
|
||||
AthleteId = athleteId,
|
||||
DateFrom = from,
|
||||
DateTo = to,
|
||||
}).ToList();
|
||||
// performed — только в плановые дни недели (spec 05)
|
||||
var performedList = inWeek
|
||||
.Where(w => plannedDays.Contains(DayName(DateOnly.FromDateTime(w.StartedAt))))
|
||||
.ToList();
|
||||
var adherences = performedList
|
||||
.Select(w => metrics.Select(new WorkoutMetricsFilter
|
||||
{ AthleteId = athleteId, WorkoutId = w.Id }).FirstOrDefault()?.Adherence)
|
||||
.Where(a => a != null).ToList();
|
||||
rows.Add(new
|
||||
{
|
||||
week = i + 1,
|
||||
from = from.ToString("yyyy-MM-dd"),
|
||||
to = to.ToString("yyyy-MM-dd"),
|
||||
planned,
|
||||
performed = performedList.Count,
|
||||
missed = Math.Max(0, planned - performedList.Count),
|
||||
avgAdherence = adherences.Count > 0
|
||||
? Math.Round(adherences.Average(a => a!.Value), 1, MidpointRounding.AwayFromZero)
|
||||
: (decimal?)null,
|
||||
});
|
||||
}
|
||||
return new { activeVersion = (int?)active.Version, weeks = rows };
|
||||
}
|
||||
|
||||
/// <summary>Дни недели сессий по неделям body (v1 — каждая сессия
|
||||
/// = одна плановая тренировка; ротация недель — v2).</summary>
|
||||
private static List<List<string>> ParseWeeks(string body)
|
||||
{
|
||||
var result = new List<List<string>>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray())
|
||||
{
|
||||
var days = new List<string>();
|
||||
if (week.TryGetProperty("sessions", out var sessionNodes))
|
||||
foreach (var session in sessionNodes.EnumerateArray())
|
||||
if (session.TryGetProperty("day", out var day) && day.ValueKind == JsonValueKind.String)
|
||||
days.Add(day.GetString()!);
|
||||
result.Add(days);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or KeyNotFoundException)
|
||||
{
|
||||
// кривой body — сводка пустая
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DateOnly MondayOf(DateOnly date) =>
|
||||
date.AddDays(-(((int)date.DayOfWeek + 6) % 7));
|
||||
|
||||
private static string DayName(DateOnly date) => DayNames[((int)date.DayOfWeek + 6) % 7];
|
||||
}
|
||||
|
|
@ -45,6 +45,9 @@ public static class WebAppStartup
|
|||
builder.Services.AddSingleton<Core.Programs.ProgressionRules>();
|
||||
builder.Services.AddSingleton<Core.Programs.BreakEngine>();
|
||||
builder.Services.AddSingleton<SyncService>();
|
||||
builder.Services.AddSingleton(new Services.AdminAuthService(
|
||||
config.AdminLogin, config.AdminPasswordHash));
|
||||
builder.Services.AddSingleton<Services.PlanSummaryService>();
|
||||
|
||||
builder.Services.AddControllersWithViews(o => { o.EnableEndpointRouting = false; })
|
||||
// контроллеры живут в сборке Web — при хостинге из тестов входная
|
||||
|
|
@ -59,6 +62,8 @@ public static class WebAppStartup
|
|||
|
||||
var app = builder.Build();
|
||||
app.UseMiddleware<ApiKeyMiddleware>();
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { DefaultFileNames = { "admin/index.html" } });
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseMvc(routes => RouteConfig.Configure(routes));
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: web
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# admin_web
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- web/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Сборка admin-консоли (spec 06 §3 m08): flutter build web -> wwwroot\admin
|
||||
# Требует Flutter в PATH (SDK: C:\tools\flutter).
|
||||
$ErrorActionPreference = 'Stop'
|
||||
flutter build web --release
|
||||
$target = Join-Path $PSScriptRoot '..\..\2-cli\Server.WatchBody.Web\wwwroot\admin'
|
||||
if (Test-Path $target) { Remove-Item -Recurse -Force $target }
|
||||
New-Item -ItemType Directory -Force -Path $target | Out-Null
|
||||
Copy-Item -Recurse -Force (Join-Path $PSScriptRoot 'build\web\*') $target
|
||||
Write-Host "OK -> $target"
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:html' as html;
|
||||
|
||||
/// API-клиент admin-консоли (spec 06 m08): токен в sessionStorage,
|
||||
/// 401 → [UnauthorizedException] → экран логина.
|
||||
class UnauthorizedException implements Exception {}
|
||||
|
||||
class Api {
|
||||
static const _base = '';
|
||||
static String? token = html.window.sessionStorage['adminToken'];
|
||||
|
||||
static bool get authorized => token != null && token!.isNotEmpty;
|
||||
|
||||
static void logout() {
|
||||
token = null;
|
||||
html.window.sessionStorage.remove('adminToken');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> login(
|
||||
String login, String password) async {
|
||||
final data = await _send('POST', '/api/admin/login',
|
||||
body: {'login': login, 'password': password});
|
||||
token = data['token'] as String;
|
||||
html.window.sessionStorage['adminToken'] = token!;
|
||||
return data;
|
||||
}
|
||||
|
||||
static Future<dynamic> get(String path) => _send('GET', path);
|
||||
|
||||
static Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
|
||||
_send('POST', path, body: body);
|
||||
|
||||
/// CSV — запрос с Bearer и Blob-download (spec 06 §2.3).
|
||||
static Future<void> downloadCsv(String query) async {
|
||||
final request = html.HttpRequest()
|
||||
..open('GET', _base + query)
|
||||
..responseType = 'blob'
|
||||
..setRequestHeader('Authorization', 'Bearer $token');
|
||||
await request.onLoad.first;
|
||||
if (request.status == 401) {
|
||||
logout();
|
||||
throw UnauthorizedException();
|
||||
}
|
||||
final blob = request.response as html.Blob;
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..download = 'workouts.csv'
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
}
|
||||
|
||||
static Future<dynamic> _send(String method, String path,
|
||||
{Map<String, dynamic>? body}) async {
|
||||
final request = html.HttpRequest()
|
||||
..open(method, _base + path)
|
||||
..setRequestHeader('Content-Type', 'application/json');
|
||||
if (token != null) {
|
||||
request.setRequestHeader('Authorization', 'Bearer $token');
|
||||
}
|
||||
await request.onLoad.first;
|
||||
// ignore: only_throw_errors
|
||||
if (request.status == 401) {
|
||||
logout();
|
||||
throw UnauthorizedException();
|
||||
}
|
||||
if (request.status! >= 400) {
|
||||
throw Exception('HTTP ${request.status}: ${request.responseText}');
|
||||
}
|
||||
final text = request.responseText ?? '';
|
||||
return text.isEmpty ? <String, dynamic>{} : jsonDecode(text);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'screens.dart';
|
||||
|
||||
/// Админ-консоль «Феникс 6X» (m08): Flutter Web, Material 3,
|
||||
/// тёмная тема (UI/UX-стандарт). Токен — sessionStorage; 401 → логин.
|
||||
void main() => runApp(const AdminApp());
|
||||
|
||||
class AdminApp extends StatelessWidget {
|
||||
const AdminApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'Феникс 6X — консоль владельца',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorSchemeSeed: const Color(0xFF4FC3F7),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const RootPage(),
|
||||
);
|
||||
}
|
||||
|
||||
class RootPage extends StatefulWidget {
|
||||
const RootPage({super.key});
|
||||
|
||||
@override
|
||||
State<RootPage> createState() => _RootPageState();
|
||||
}
|
||||
|
||||
class _RootPageState extends State<RootPage> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Api.authorized) return LoginPage(onDone: _reload);
|
||||
const pages = [
|
||||
AthletesPage(),
|
||||
WorkoutsPage(),
|
||||
ProgramsPage(),
|
||||
MetricsPage(),
|
||||
PlanPage(),
|
||||
];
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.people_outline),
|
||||
selectedIcon: Icon(Icons.people),
|
||||
label: Text('Атлеты')),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.fitness_center_outlined),
|
||||
selectedIcon: Icon(Icons.fitness_center),
|
||||
label: Text('Тренировки')),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.list_alt_outlined),
|
||||
selectedIcon: Icon(Icons.list_alt),
|
||||
label: Text('Программы')),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.insights_outlined),
|
||||
selectedIcon: Icon(Icons.insights),
|
||||
label: Text('Метрики')),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.event_available_outlined),
|
||||
selectedIcon: Icon(Icons.event_available),
|
||||
label: Text('План/факт')),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
Api.logout();
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Выйти'),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: pages[_index]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reload() => setState(() {});
|
||||
}
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key, required this.onDone});
|
||||
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _login = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Феникс 6X',
|
||||
style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('консоль владельца'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _login,
|
||||
decoration: const InputDecoration(labelText: 'Логин')),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => _submit(),
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Пароль')),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Войти'),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(_error!,
|
||||
style: const TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _submit() async {
|
||||
try {
|
||||
await Api.login(_login.text, _password.text);
|
||||
widget.onDone();
|
||||
} on UnauthorizedException {
|
||||
setState(() => _error = 'Неверный логин или пароль');
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Ошибка: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'api.dart';
|
||||
|
||||
// ---------- общие ----------
|
||||
|
||||
typedef Json = Map<String, dynamic>;
|
||||
|
||||
class FutureBuilderJson extends StatelessWidget {
|
||||
const FutureBuilderJson(this.future, this.builder, {super.key});
|
||||
|
||||
final Future<dynamic>? future;
|
||||
final Widget Function(dynamic data) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(child: Text('Ошибка: ${snap.error}'));
|
||||
}
|
||||
return builder(snap.data);
|
||||
});
|
||||
}
|
||||
|
||||
/// Выпадающий список атлетов (общий для 4 экранов).
|
||||
class AthletePicker extends StatelessWidget {
|
||||
const AthletePicker({super.key, required this.selected, required this.onChanged});
|
||||
|
||||
final int? selected;
|
||||
final void Function(int?) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
FutureBuilderJson(Api.get('/api/admin/athletes'), (data) {
|
||||
final items = (data['items'] as List).cast<Json>();
|
||||
return DropdownButton<int>(
|
||||
hint: const Text('Атлет'),
|
||||
value: selected != null &&
|
||||
items.any((a) => a['id'] == selected)
|
||||
? selected
|
||||
: null,
|
||||
items: [
|
||||
for (final a in items)
|
||||
DropdownMenuItem(
|
||||
value: a['id'] as int,
|
||||
child: Text('${a['name']} (#${a['id']})'
|
||||
'${a['isBlocked'] == true ? " [заблокирован]" : ""}')),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 1. Атлеты ----------
|
||||
|
||||
class AthletesPage extends StatefulWidget {
|
||||
const AthletesPage({super.key});
|
||||
|
||||
@override
|
||||
State<AthletesPage> createState() => _AthletesPageState();
|
||||
}
|
||||
|
||||
class _AthletesPageState extends State<AthletesPage> {
|
||||
Future<dynamic>? _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reload();
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _future = Api.get('/api/admin/athletes'));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => _createDialog(context),
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Добавить атлета'),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilderJson(_future, (data) {
|
||||
final items = (data['items'] as List).cast<Json>();
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(columns: const [
|
||||
DataColumn(label: Text('id')),
|
||||
DataColumn(label: Text('Имя')),
|
||||
DataColumn(label: Text('Заблокирован')),
|
||||
DataColumn(label: Text('Создан')),
|
||||
DataColumn(label: Text('Действия')),
|
||||
], rows: [
|
||||
for (final a in items)
|
||||
DataRow(cells: [
|
||||
DataCell(Text('${a['id']}')),
|
||||
DataCell(Text('${a['name']}')),
|
||||
DataCell(Text(a['isBlocked'] == true ? 'да' : 'нет')),
|
||||
DataCell(Text('${a['createdAt']}'.split('T').first)),
|
||||
DataCell(Row(children: [
|
||||
TextButton(
|
||||
onPressed: () => _newKeyDialog(context, a),
|
||||
child: const Text('Смена ключа')),
|
||||
Switch(
|
||||
value: a['isBlocked'] == true,
|
||||
onChanged: (blocked) async {
|
||||
await Api.post(
|
||||
'/api/admin/athletes/${a['id']}/block',
|
||||
{'isBlocked': blocked});
|
||||
_reload();
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
Future<void> _createDialog(BuildContext context) async {
|
||||
final nameController = TextEditingController();
|
||||
final key = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Новый атлет'),
|
||||
content: TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Имя')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final created = await Api.post('/api/admin/athletes',
|
||||
{'name': nameController.text});
|
||||
if (context.mounted) Navigator.pop(context, created['apiKey']);
|
||||
},
|
||||
child: const Text('Создать')),
|
||||
],
|
||||
),
|
||||
);
|
||||
_reload();
|
||||
if (key is String && mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('API-ключ создан'),
|
||||
content: Text('Ключ показывается ОДИН раз:\n\n$key\n\n'
|
||||
'Передайте его атлету для настройки телефона.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Понятно')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _newKeyDialog(BuildContext context, Json athlete) async {
|
||||
final reissued = await Api.post('/api/admin/athletes/${athlete['id']}/key');
|
||||
if (!mounted) return;
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Новый API-ключ'),
|
||||
content: Text('Старый ключ больше не работает.\n\n${reissued['apiKey']}'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context), child: const Text('Ок')),
|
||||
],
|
||||
),
|
||||
);
|
||||
_reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. Тренировки ----------
|
||||
|
||||
class WorkoutsPage extends StatefulWidget {
|
||||
const WorkoutsPage({super.key});
|
||||
|
||||
@override
|
||||
State<WorkoutsPage> createState() => _WorkoutsPageState();
|
||||
}
|
||||
|
||||
class _WorkoutsPageState extends State<WorkoutsPage> {
|
||||
int? _athleteId;
|
||||
String? _from, _to;
|
||||
int _offset = 0;
|
||||
static const _limit = 50;
|
||||
|
||||
String get _query {
|
||||
var query = 'athleteId=${_athleteId ?? ''}&limit=$_limit&offset=$_offset';
|
||||
if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from';
|
||||
if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to';
|
||||
return query;
|
||||
}
|
||||
|
||||
String get _csvQuery {
|
||||
var query = 'athleteId=${_athleteId ?? ''}';
|
||||
if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from';
|
||||
if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to';
|
||||
return query;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Wrap(spacing: 16, children: [
|
||||
AthletePicker(
|
||||
selected: _athleteId,
|
||||
onChanged: (id) => setState(() {
|
||||
_athleteId = id;
|
||||
_offset = 0;
|
||||
})),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() { _from = v; _offset = 0; }),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'с (ГГГГ-ММ-ДД)'))),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() { _to = v; _offset = 0; }),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'по (ГГГГ-ММ-ДД)'))),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Api.downloadCsv('/api/admin/workouts.csv?$_csvQuery'),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('CSV'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilderJson(Api.get('/api/admin/workouts?$_query'),
|
||||
(data) {
|
||||
final items = (data['items'] as List).cast<Json>();
|
||||
final total = data['total'] as int;
|
||||
return Column(children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(columns: const [
|
||||
DataColumn(label: Text('Дата')),
|
||||
DataColumn(label: Text('Атлет')),
|
||||
DataColumn(label: Text('Мин')),
|
||||
DataColumn(label: Text('Ср. пульс')),
|
||||
DataColumn(label: Text('Подходы')),
|
||||
DataColumn(label: Text('Adherence')),
|
||||
DataColumn(label: Text('Вид')),
|
||||
], rows: [
|
||||
for (final w in items)
|
||||
DataRow(onSelectChanged: (_) => _openCard(w['workoutId']), cells: [
|
||||
DataCell(Text('${w['startedAt']}'.split('T').first)),
|
||||
DataCell(Text('${w['athleteName']}')),
|
||||
DataCell(Text('${w['durationMin'] ?? '—'}')),
|
||||
DataCell(Text('${w['avgHr'] ?? '—'}')),
|
||||
DataCell(Text('${w['setsCount']}')),
|
||||
DataCell(Text('${w['adherence'] ?? '—'}')),
|
||||
DataCell(Text('${w['sessionKind']}')),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
TextButton(
|
||||
onPressed: _offset >= _limit
|
||||
? () => setState(() => _offset -= _limit)
|
||||
: null,
|
||||
child: const Text('← Назад')),
|
||||
Text(' ${_offset + 1}–${_offset + items.length} из $total '),
|
||||
TextButton(
|
||||
onPressed: _offset + _limit < total
|
||||
? () => setState(() => _offset += _limit)
|
||||
: null,
|
||||
child: const Text('Вперёд →')),
|
||||
]),
|
||||
]);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _openCard(dynamic workoutId) async {
|
||||
final card = await Api.get('/api/admin/workouts/$workoutId');
|
||||
if (!mounted) return;
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Тренировка ${card['startedAt']}'.split('T').first),
|
||||
content: SizedBox(
|
||||
width: 720,
|
||||
height: 480,
|
||||
child: DefaultTabController(
|
||||
length: 3,
|
||||
child: Column(children: [
|
||||
const TabBar(tabs: [
|
||||
Tab(text: 'План/факт'),
|
||||
Tab(text: 'Подходы (.fit)'),
|
||||
Tab(text: 'Пульсограмма'),
|
||||
]),
|
||||
Expanded(
|
||||
child: TabBarView(children: [
|
||||
_planFact(card),
|
||||
_timeline(card),
|
||||
_hrCurve(card),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context), child: const Text('Закрыть')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _planFact(Json card) {
|
||||
final planFact = card['planFact'] as Json?;
|
||||
final plan = (planFact?['plan'] as List? ?? []).cast<Json>();
|
||||
if (plan.isEmpty) {
|
||||
return const Center(child: Text('План на этот день не найден'));
|
||||
}
|
||||
return DataTable(columns: const [
|
||||
DataColumn(label: Text('Упражнение')),
|
||||
DataColumn(label: Text('План')),
|
||||
DataColumn(label: Text('Факт')),
|
||||
], rows: [
|
||||
for (final p in plan)
|
||||
DataRow(cells: [
|
||||
DataCell(Text('${p['exercise']}')),
|
||||
DataCell(Text(
|
||||
'${p['plannedSets']}×${p['plannedReps'] ?? '—'} @ ${p['plannedWeight'] ?? '—'} кг')),
|
||||
DataCell(Text(
|
||||
'${p['factSets']}×${p['factReps'] ?? '—'} @ ${p['factWeight'] ?? '—'} кг')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _timeline(Json card) {
|
||||
final timeline = (card['timeline'] as List? ?? []).cast<Json>();
|
||||
if (timeline.isEmpty) {
|
||||
return const Center(child: Text('Данных .fit нет'));
|
||||
}
|
||||
return ListView(children: [
|
||||
for (final t in timeline)
|
||||
ListTile(
|
||||
title: Text('Подход ${t['index']}: ${t['durationS']} c'),
|
||||
subtitle: Text('старт +${t['startOffsetS']} c · '
|
||||
'пульс ${t['avgHr'] ?? '—'}/${t['peakHr'] ?? '—'} · '
|
||||
'отдых ${t['restAfterS']} c'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _hrCurve(Json card) {
|
||||
final curve = (card['hrCurve'] as List? ?? []).cast<Json>();
|
||||
if (curve.isEmpty) {
|
||||
return const Center(child: Text('Данных .fit нет'));
|
||||
}
|
||||
return CustomPaint(
|
||||
painter: _HrCurvePainter(curve),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HrCurvePainter extends CustomPainter {
|
||||
_HrCurvePainter(this.points);
|
||||
|
||||
final List<Json> points;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (points.length < 2) return;
|
||||
final maxT = (points.last['t'] as num).toDouble();
|
||||
final minHr = points
|
||||
.map((p) => (p['hr'] as num).toDouble())
|
||||
.reduce((a, b) => a < b ? a : b);
|
||||
final maxHr = points
|
||||
.map((p) => (p['hr'] as num).toDouble())
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
final spanHr = (maxHr - minHr).clamp(1.0, double.infinity);
|
||||
Offset pointAt(Json p) => Offset(
|
||||
(p['t'] as num).toDouble() / maxT * size.width,
|
||||
size.height -
|
||||
((p['hr'] as num).toDouble() - minHr) / spanHr * size.height,
|
||||
);
|
||||
final path = Path()..moveTo(0, pointAt(points.first).dy);
|
||||
for (final p in points) {
|
||||
final o = pointAt(p);
|
||||
path.lineTo(o.dx, o.dy);
|
||||
}
|
||||
final paintLine = Paint()
|
||||
..color = const Color(0xFF4FC3F7)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
canvas.drawPath(path, paintLine);
|
||||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||||
tp.text = const TextSpan(text: '');
|
||||
tp.layout();
|
||||
tp.paint(canvas, Offset.zero);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
// ---------- 3. Программы ----------
|
||||
|
||||
class ProgramsPage extends StatefulWidget {
|
||||
const ProgramsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ProgramsPage> createState() => _ProgramsPageState();
|
||||
}
|
||||
|
||||
class _ProgramsPageState extends State<ProgramsPage> {
|
||||
int? _athleteId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: AthletePicker(
|
||||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||||
),
|
||||
Expanded(
|
||||
child: _athleteId == null
|
||||
? const Center(child: Text('Выберите атлета'))
|
||||
: FutureBuilderJson(
|
||||
Api.get('/api/admin/programs?athleteId=$_athleteId'),
|
||||
(data) {
|
||||
final items = (data['items'] as List).cast<Json>();
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: Text('Версий нет'));
|
||||
}
|
||||
return ListView(children: [
|
||||
for (final v in items)
|
||||
ListTile(
|
||||
title: Text('v${v['version']} · ${v['status']} · '
|
||||
'${v['author']}'),
|
||||
subtitle: Text('${v['rationale']}'),
|
||||
trailing: Wrap(children: [
|
||||
if (v['author'] == 'rules' && v['status'] == 'draft')
|
||||
TextButton(
|
||||
onPressed: () => _confirm(
|
||||
'Применить предложение правил?',
|
||||
() => Api.post(
|
||||
'/api/admin/programs/${v['versionId']}/apply?athleteId=$_athleteId'),
|
||||
reload: true),
|
||||
child: const Text('Применить'),
|
||||
),
|
||||
if (v['status'] == 'draft')
|
||||
TextButton(
|
||||
onPressed: () => _confirm(
|
||||
'Активировать v${v['version']}?',
|
||||
() => Api.post(
|
||||
'/api/admin/programs/${v['versionId']}/activate?athleteId=$_athleteId'),
|
||||
reload: true),
|
||||
child: const Text('Активировать'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showBody(
|
||||
v['versionId'], v['version']),
|
||||
child: const Text('Body'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _confirm(String title, Future<dynamic> Function() action,
|
||||
{bool reload = false}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Отмена')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Да')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await action();
|
||||
if (reload && mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showBody(dynamic versionId, dynamic version) async {
|
||||
final v = await Api.get(
|
||||
'/api/admin/programs/$versionId?athleteId=$_athleteId');
|
||||
if (!mounted) return;
|
||||
final pretty = const JsonEncoder.withIndent(' ')
|
||||
.convert(jsonDecode(v['body'] as String));
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Body v$version'),
|
||||
content: SizedBox(
|
||||
width: 640,
|
||||
height: 480,
|
||||
child: SingleChildScrollView(child: SelectableText(pretty))),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Закрыть')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 4. Метрики ----------
|
||||
|
||||
class MetricsPage extends StatefulWidget {
|
||||
const MetricsPage({super.key});
|
||||
|
||||
@override
|
||||
State<MetricsPage> createState() => _MetricsPageState();
|
||||
}
|
||||
|
||||
class _MetricsPageState extends State<MetricsPage> {
|
||||
int? _athleteId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: AthletePicker(
|
||||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||||
),
|
||||
Expanded(
|
||||
child: _athleteId == null
|
||||
? const Center(child: Text('Выберите атлета'))
|
||||
: FutureBuilderJson(
|
||||
Api.get('/api/admin/metrics?athleteId=$_athleteId'),
|
||||
(data) {
|
||||
final weeks = (data['weekly'] as List).cast<Json>();
|
||||
final workouts = (data['workouts'] as List).cast<Json>();
|
||||
return Column(children: [
|
||||
if (weeks.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: CustomPaint(
|
||||
painter: _WeeklyChartPainter(weeks),
|
||||
size: Size.infinite,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DataTable(columns: const [
|
||||
DataColumn(label: Text('Дата')),
|
||||
DataColumn(label: Text('Adherence')),
|
||||
DataColumn(label: Text('Тоннаж')),
|
||||
DataColumn(label: Text('Флаги')),
|
||||
], rows: [
|
||||
for (final m in workouts)
|
||||
DataRow(cells: [
|
||||
DataCell(Text('${m['date'] ?? '—'}')),
|
||||
DataCell(Text('${m['adherence']}')),
|
||||
DataCell(Text('${m['tonnage']}')),
|
||||
DataCell(Text(_flags(m))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
String _flags(Json m) {
|
||||
final flags = m['redFlags'];
|
||||
if (flags is List) return flags.isEmpty ? '—' : '${flags.length}';
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
class _WeeklyChartPainter extends CustomPainter {
|
||||
_WeeklyChartPainter(this.weeks);
|
||||
|
||||
final List<Json> weeks;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (weeks.isEmpty) return;
|
||||
final maxTonnage = weeks
|
||||
.map((w) => (w['tonnage'] as num).toDouble())
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
final barWidth = size.width / weeks.length * 0.6;
|
||||
final step = size.width / weeks.length;
|
||||
final barPaint = Paint()..color = const Color(0x804FC3F7);
|
||||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||||
for (var i = 0; i < weeks.length; i++) {
|
||||
final tonnage = (weeks[i]['tonnage'] as num).toDouble();
|
||||
final h = tonnage / maxTonnage * (size.height - 40);
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(i * step + (step - barWidth) / 2,
|
||||
size.height - 24 - h, barWidth, h),
|
||||
barPaint);
|
||||
// подписи текстом (цвет дублируется значением — UI/UX-стандарт)
|
||||
tp.text = TextSpan(
|
||||
text: '${weeks[i]['week']}'.split('-').last,
|
||||
style: const TextStyle(color: Color(0xFFB0BEC5), fontSize: 11));
|
||||
tp.layout();
|
||||
tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 20));
|
||||
tp.text = TextSpan(
|
||||
text: '${weeks[i]['avgAdherence']}%',
|
||||
style: const TextStyle(color: Color(0xFF80CBC4), fontSize: 11));
|
||||
tp.layout();
|
||||
tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 40));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
// ---------- 5. План/факт ----------
|
||||
|
||||
class PlanPage extends StatefulWidget {
|
||||
const PlanPage({super.key});
|
||||
|
||||
@override
|
||||
State<PlanPage> createState() => _PlanPageState();
|
||||
}
|
||||
|
||||
class _PlanPageState extends State<PlanPage> {
|
||||
int? _athleteId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: AthletePicker(
|
||||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||||
),
|
||||
Expanded(
|
||||
child: _athleteId == null
|
||||
? const Center(child: Text('Выберите атлета'))
|
||||
: FutureBuilderJson(
|
||||
Api.get('/api/admin/plan?athleteId=$_athleteId'), (data) {
|
||||
if (data['activeVersion'] == null) {
|
||||
return const Center(child: Text('Активной версии нет'));
|
||||
}
|
||||
final weeks = (data['weeks'] as List).cast<Json>();
|
||||
return ListView(children: [
|
||||
for (final w in weeks)
|
||||
Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'Неделя ${w['week']} (${w['from']} — ${w['to']})'),
|
||||
subtitle: Text(
|
||||
'запланировано ${w['planned']} · '
|
||||
'выполнено ${w['performed']} · '
|
||||
'пропущено ${w['missed']}'),
|
||||
trailing: Text(
|
||||
'${w['avgAdherence'] ?? '—'}%',
|
||||
style: TextStyle(
|
||||
color: (w['missed'] as int) > 0
|
||||
? Colors.orangeAccent
|
||||
: Colors.greenAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.4.0"
|
||||
sdks:
|
||||
dart: ">=3.13.5 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
name: admin_web
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="admin_web">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>admin_web</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "admin_web",
|
||||
"short_name": "admin_web",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue