Этап 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:
virus-home 2026-10-19 01:31:45 +05:00
parent f41bf9a0d2
commit d61499025c
43 changed files with 3117 additions and 5 deletions

3
.gitignore vendored
View File

@ -17,3 +17,6 @@ Thumbs.db
# Сборки и временные файлы # Сборки и временные файлы
*.log *.log
tmp/ tmp/
# Сборка admin-консоли (m08) — артефакт flutter build (3-app-webdmin-webuild.ps1)
3-app/2-cli/Server.WatchBody.Web/wwwroot/admin/

View File

@ -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) | | 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) | | 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) | | 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 | ⬜ | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ |
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ |
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ | | m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ |

View File

@ -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/)

View File

@ -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` там не действуют).

View File

@ -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 — без токена.

View File

@ -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: только чтение.

View File

@ -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 — статус не позволяет.

View File

@ -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":[]}`.

View File

@ -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-БД).

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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: ools lutter`, 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).

View File

@ -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());
}
}

View File

@ -7,6 +7,10 @@
{ "name": "Test Анна", "apiKey": "test-key-anna-0003" } { "name": "Test Анна", "apiKey": "test-key-anna-0003" }
] ]
}, },
"Admin": {
"Login": "test-admin",
"PasswordHash": "pbkdf2$100000$AAECAwQFBgcICQoLDA0ODw==$Gq5LDyI8L0fc+2TIN8q5dyn/GgAvxyHd8vn6BFz5rjw="
},
"Db": { "Db": {
"ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody" "ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody"
}, },

View File

@ -28,5 +28,59 @@ public class RouteConfig
routeBuilder.AddController<ProgramController>() routeBuilder.AddController<ProgramController>()
.ActionAction("api/v1/programs/active", x => x.Active) .ActionAction("api/v1/programs/active", x => x.Active)
.NeedHttpGet(); .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();
} }
} }

View File

@ -34,6 +34,10 @@ public class ServerConfig
public string ImportArchiveDir { get; set; } = public string ImportArchiveDir { get; set; } =
Path.Combine(AppContext.BaseDirectory, "data", "import_done"); 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> /// <summary>Учётка Garmin Connect владельца (--mode fetch, m07).</summary>
public string GarminLogin { get; set; } = string.Empty; public string GarminLogin { get; set; } = string.Empty;
public string GarminPassword { 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 (root.TryGetProperty("Db", out var db))
{ {
if (db.TryGetProperty("ConnectionString", out var cs)) if (db.TryGetProperty("ConnectionString", out var cs))

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 });
}
}

View File

@ -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));
}

View File

@ -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);
}
}
}

View File

@ -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) : "");
}
}

View File

@ -14,10 +14,10 @@ public class ApiKeyMiddleware(RequestDelegate next)
public async Task InvokeAsync(HttpContext context, IAthleteRepository athletes) public async Task InvokeAsync(HttpContext context, IAthleteRepository athletes)
{ {
// /health без ключа — только GET (З-42 п. 8): прочие методы идут // ключ атлета охраняет только телефонный контур /api/v1/*:
// в аутентификацию и получают 401, а не деградацию 404/405 // /api/admin — Bearer-контур владельца (m08, 🛡1), статика
if (context.Request.Path == "/health" && // консоли (/admin) и /health — без ключа
HttpMethods.IsGet(context.Request.Method)) if (!context.Request.Path.StartsWithSegments("/api/v1"))
{ {
await next(context); await next(context);
return; return;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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];
}

View File

@ -45,6 +45,9 @@ public static class WebAppStartup
builder.Services.AddSingleton<Core.Programs.ProgressionRules>(); builder.Services.AddSingleton<Core.Programs.ProgressionRules>();
builder.Services.AddSingleton<Core.Programs.BreakEngine>(); builder.Services.AddSingleton<Core.Programs.BreakEngine>();
builder.Services.AddSingleton<SyncService>(); 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; }) builder.Services.AddControllersWithViews(o => { o.EnableEndpointRouting = false; })
// контроллеры живут в сборке Web — при хостинге из тестов входная // контроллеры живут в сборке Web — при хостинге из тестов входная
@ -59,6 +62,8 @@ public static class WebAppStartup
var app = builder.Build(); var app = builder.Build();
app.UseMiddleware<ApiKeyMiddleware>(); app.UseMiddleware<ApiKeyMiddleware>();
app.UseDefaultFiles(new DefaultFilesOptions { DefaultFileNames = { "admin/index.html" } });
app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseMvc(routes => RouteConfig.Configure(routes)); app.UseMvc(routes => RouteConfig.Configure(routes));
return app; return app;

48
3-app/3-web/admin-web/.gitignore vendored Normal file
View File

@ -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/

View File

@ -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'

View File

@ -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.

View File

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

View File

@ -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"

View File

@ -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);
}
}

View File

@ -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');
}
}
}

View File

@ -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,
),
),
),
),
]);
}),
),
],
);
}

View File

@ -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"

View File

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

View File

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

View File

@ -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"
}
]
}