Compare commits
1
Commits
main
..
dist/server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f645dcd3 |
@@ -1,13 +0,0 @@
|
||||
# Что не должно попадать в контекст сборки.
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# Локальная сборка фронта: в образе он собирается заново.
|
||||
frontend/dist
|
||||
|
||||
# Вендоренная копия ядра здесь НЕ исключается и исключаться не должна: в образ
|
||||
# она обязана приехать вместе с исходниками, иначе сборка встанет на импорте
|
||||
# @core. Ровно эту ошибку проверяет `npm run doctor`.
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
||||
# Сборка шаблона
|
||||
frontend/dist
|
||||
|
||||
# Вендоренная копия ядра в шаблоне (npm run update)
|
||||
frontend/src/core
|
||||
backend/core
|
||||
|
||||
# Доктор в шаблоне — копия scripts/doctor.mjs, её кладёт тот же update.sh.
|
||||
# В настоящей панели он, наоборот, коммитится: там ma7engine рядом может не быть.
|
||||
frontend/scripts/doctor.mjs
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Панель — один процесс и один образ: express отдаёт и API, и собранный фронт
|
||||
# (core/server/spa.ts), отдельный nginx ей не нужен.
|
||||
#
|
||||
# Контекст сборки — каталог самой панели, и ничего выше него не требуется:
|
||||
# вендоренная копия ядра закоммичена вместе с исходниками, поэтому образ
|
||||
# собирается из чистого checkout'а, без ma7engine рядом. Обновляется копия
|
||||
# отдельно и осознанно — `npm run update` во фронте, диффом в ревью.
|
||||
#
|
||||
# docker build -t admin-hub .
|
||||
# docker run -p 8080:8080 -e ADMIN_PASS=… -e ADMIN_JWT_SECRET=… admin-hub
|
||||
|
||||
# ─── фронт: собирается и остаётся только как dist ───────────────────────────
|
||||
FROM node:24-alpine AS frontend
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
# prebuild зовёт update.sh. Ядра рядом в образе нет, и скрипт штатно берёт уже
|
||||
# синхронизированную копию из src/core — это его нормальная ветка, не ошибка.
|
||||
RUN npm run build
|
||||
|
||||
# ─── рантайм: бэкенд плюс собранный фронт ───────────────────────────────────
|
||||
FROM node:24-alpine AS runtime
|
||||
WORKDIR /app/backend
|
||||
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
# tsx лежит в dependencies намеренно: сервер и в деве, и в образе стартует одной
|
||||
# и той же командой. Компиляция в JS дала бы образ меньше, но развела бы дев и
|
||||
# прод, а в этой панели пути и команды всюду одинаковы — на том же основании
|
||||
# ядро ездит копией, а не алиасом.
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY backend/ ./
|
||||
# staticDir по умолчанию — ../frontend/dist от каталога сервера.
|
||||
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# NODE_ENV=production включает разбор X-Forwarded-For в рейт-лимите: за прокси
|
||||
# без него все клиенты схлопываются в один ключ и блокируют вход всем сразу.
|
||||
#
|
||||
# ADMIN_PASS (или ADMIN_PASS_HASH) и ADMIN_JWT_SECRET значений по умолчанию не
|
||||
# имеют — без них процесс не поднимется. Это не недосмотр: тихо работающая
|
||||
# панель с паролем по умолчанию хуже не стартовавшей.
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || exit 1
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,532 +0,0 @@
|
||||
# ma7engine
|
||||
|
||||
Общее ядро админ-панелей: оболочка, реестр вкладок и дизайн-система. Родилось из
|
||||
трёх форков одного каркаса (`admin-hub`, `admin-ma7`, `awg-ui`), где ~3600 строк
|
||||
жили в трёх копиях: 77 из 78 CSS-правил и 194 из 197 строк `App.tsx` совпадали
|
||||
побайтово.
|
||||
|
||||
```
|
||||
core/ ядро; половины разъезжаются по панели врозь
|
||||
browser/ браузер → <панель>/frontend/src/core
|
||||
shell/ <Panel config={…} /> — логин, тема, drawer/сайдбар, снэкбар
|
||||
shell/styles.css — дизайн-система (публичный контракт классов)
|
||||
tabs/ buildTabs() — валидация манифестов, порядок, права, категории
|
||||
http/ apiFetch, errText
|
||||
utils/ bytes, timeAgo, fmtDateTime, copyText, downloadFile
|
||||
auth/ hasPermission, OWNER_PERMISSION, типы сессии и PageProps
|
||||
icons/ 10 иконок, совпадавших во всех панелях
|
||||
|
||||
server/ node → <панель>/backend/core
|
||||
index.ts createPanelServer() — сборка всего в правильном порядке
|
||||
ratelimit.ts лимит попыток логина по IP (с разбором X-Forwarded-For)
|
||||
password.ts сравнение за постоянное время, scrypt-хэш, fail-closed
|
||||
session.ts выпуск/проверка JWT, отзыв токена
|
||||
auth.ts requireAuth, requirePermission
|
||||
spa.ts раздача собранного фронта с fallback
|
||||
errors.ts проброс статуса и текста ошибки вышестоящего API
|
||||
|
||||
frontend/ шаблон панели: конфиг + четыре вкладки
|
||||
backend/server.ts шаблон бэкенда: конфиг + три демо-маршрута
|
||||
Dockerfile один процесс и один образ: express отдаёт API и фронт
|
||||
scripts/new-panel.mjs заводит новую панель из шаблона (npm run new-panel)
|
||||
scripts/release.sh split-ветки половин ядра для subtree (npm run release)
|
||||
scripts/doctor.mjs проверяет, что панель по стандарту (npm run doctor)
|
||||
scripts/update.sh раскладывает ядро внутри шаблона (npm run update)
|
||||
```
|
||||
|
||||
Половины лежат врозь не по вкусу, а потому что это две разные сборки: у них
|
||||
разные tsconfig (`lib: DOM` + JSX + резолв бандлера против `lib: ES2020` +
|
||||
CommonJS), разные адресаты копии и разные зависимости — в `core/browser`
|
||||
импортируются только `react` и `axios`, в `core/server` — только `express`,
|
||||
`jsonwebtoken` и `node:*`. Пересечение — ноль файлов.
|
||||
|
||||
## Что уже общее
|
||||
|
||||
Всё перечисленное панель получает готовым и у себя не пишет. Список
|
||||
исчерпывающий: чего в нём нет — того нет и в ядре, значит это продуктовый код
|
||||
конкретной панели.
|
||||
|
||||
### Оболочка
|
||||
|
||||
`<Panel config={…} />` — один и тот же код у панели с ролями и без, разница
|
||||
только в конфиге:
|
||||
|
||||
- **Вход.** Форма логина с брендингом, `POST /login`, разбор ответа: 401 —
|
||||
«Неверный логин или пароль», 429 — текст лимита, пришедший с сервера,
|
||||
остальное — «Сервер недоступен».
|
||||
- **Сессия.** Токен кладётся в `localStorage` и восстанавливается при загрузке;
|
||||
протухший стирается молча, панель показывает логин.
|
||||
- **Единый разлогин по 401.** Любой `apiFetch` из любой вкладки, получивший 401,
|
||||
разлогинивает панель целиком — вкладки этот случай не обрабатывают.
|
||||
- **Выход.** `POST /logout` (сервер отзывает токен), очистка стораджа и
|
||||
состояния.
|
||||
- **Тема.** Кнопка-переключатель, `data-theme` на `<html>`, запоминание выбора.
|
||||
- **Меню.** Строится из вкладок, доступных правам текущей учётки, сгруппированных
|
||||
по категориям; пустой список даёт заглушку «вкладок пока нет» или «ни одна
|
||||
вкладка не доступна твоим правам».
|
||||
- **Мобильный режим.** Топ-бар с гамбургером, drawer с затемнением, закрытие по
|
||||
Escape, по клику в затемнение и свайпом влево, блокировка прокрутки фона.
|
||||
- **Снэкбар.** `showMsg(text)` — сообщение на 3 секунды, общий для вкладок и
|
||||
слотов.
|
||||
- **Слоты.** `aboveTab` и `headerExtra` рисуются, если заданы в конфиге. Слот
|
||||
получает активную вкладку в `ctx.tab` и сам решает, где не рисоваться.
|
||||
- **Пропсы вкладки.** `token`, `showMsg`, `username`, `userId`, `role`,
|
||||
`permissions`, `availablePermissions`, `onNavigate`.
|
||||
|
||||
Дефолты, когда конфиг молчит: ключи `admin_token` и `admin_theme`,
|
||||
`POST /login`, `POST /logout`, `GET /api/session`.
|
||||
|
||||
### Браузерные модули
|
||||
|
||||
| модуль | что даёт |
|
||||
|---|---|
|
||||
| `@core/shell` | `Panel`; типы `PanelConfig`, `SlotContext` |
|
||||
| `@core/tabs` | `buildTabs()`; типы `TabMeta`, `TabDef`, `TabGroup`, `BuiltTabs` |
|
||||
| `@core/auth` | `hasPermission()`, `OWNER_PERMISSION`, `EMPTY_SESSION`; типы `Session`, `BasePageProps`, `SessionPageProps` |
|
||||
| `@core/http` | `apiFetch()`, `errText()`, `setUnauthorizedHandler()` |
|
||||
| `@core/utils` | `fmtDateTime()`, `timeAgo()`, `bytes()`, `downloadFile()`, `copyText()` |
|
||||
| `@core/icons` | хром `IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`; карта `coreIcons` (`key`, `globe`); отдельные `IcoPlus`, `IcoTrash`, `IcoRefresh`, `IcoCopy`, `IcoGlobe`, `IcoKey` |
|
||||
|
||||
Плюс дизайн-система целиком: `core/browser/shell/styles.css` — 28 токенов палитры на `:root`
|
||||
с переопределением на `:root[data-theme="dark"]` и контракт классов (см.
|
||||
«Контракт CSS-классов»). Импортит её сама оболочка; локальный `App.css`
|
||||
подключается после.
|
||||
|
||||
### Серверные модули
|
||||
|
||||
| модуль | что даёт |
|
||||
|---|---|
|
||||
| `createPanelServer()` | сборка всего в фиксированном порядке: json → логин/сессия → маршруты панели → статика с fallback |
|
||||
| `createLoginRateLimit()`, `clientIp()` | 5 попыток с IP за 15 минут, с разбором `X-Forwarded-For` в проде |
|
||||
| `createPasswordCheck()`, `hashPassword()`, `timingSafeEquals()` | сравнение за постоянное время, `scrypt`-хэш, fail-closed при старте |
|
||||
| `createSessionStore()` | выпуск и проверка JWT (TTL 12 ч), отзыв токена |
|
||||
| `createAuth()`, `bearerToken()` | `requireAuth`, `requirePermission(perm)` |
|
||||
| `serveSpa()` | раздача `frontend/dist` с fallback; неизвестный `/api/*` — JSON-404, а не `index.html` |
|
||||
| `forwardError()` | проброс статуса и текста ошибки вышестоящего API |
|
||||
|
||||
Чем эти модули строже того, что было в панелях по отдельности, — в разделе
|
||||
«Что ядро делает строже панелей».
|
||||
|
||||
### Чего в списке нет
|
||||
|
||||
Три вещи у панелей разные и общими не станут: **конфиг** (`PanelConfig` — имя,
|
||||
версия, ключи стораджа, эндпоинты, `loadSession`, слоты), **продуктовый код**
|
||||
(вкладки, свои типы и утилиты, карта `ICONS`, маршруты бэкенда) и **дельта
|
||||
стилей** в `App.css`. Где проходит граница — в разделе «Что в ядро не идёт».
|
||||
|
||||
## Базовая форма — панель без ролей
|
||||
|
||||
Ядро по умолчанию — плоский список вкладок, доступных всем: `category`,
|
||||
`permission` и `devOnly` опциональны. Панель с правами получается включением
|
||||
слоя, а не наоборот. Это не косметика: панель, которой права не нужны, не тащит
|
||||
их машинерию и не обязана реализовывать эндпоинт сессии.
|
||||
|
||||
## Шаблон новой панели
|
||||
|
||||
Ядро здесь же и шаблон: `frontend/` и `backend/` в корне — рабочая минимальная
|
||||
панель на этом самом ядре, а не отдельный пример, который живёт своей жизнью.
|
||||
Ломающая правка ядра ломает и её, и это видно сразу.
|
||||
|
||||
### Запуск демо
|
||||
|
||||
Нужен Node 20+ (проверялось на 22). Одна команда — **из каталога `ma7engine`**,
|
||||
не из каталога, где он лежит:
|
||||
|
||||
```bash
|
||||
cd ma7engine # у меня это ~/Documents/admins/ma7engine
|
||||
npm run demo
|
||||
```
|
||||
|
||||
Скрипт при первом запуске сам поставит зависимости обеих половин, поднимет
|
||||
бэкенд на `:8080` и Vite на `:5173`, а по Ctrl+C погасит оба. Открывать нужно
|
||||
**`http://localhost:5173`**: Vite отдаёт фронт и проксирует `/login`, `/logout`
|
||||
и `/api` на бэкенд, поэтому порт нигде не зашит. Логин — `admin` / `demo`
|
||||
(переопределяется: `ADMIN_USER=root ADMIN_PASS=secret npm run demo`).
|
||||
|
||||
Если половины нужны врозь — два терминала, полные пути, потому что `npm start`
|
||||
терминал не возвращает:
|
||||
|
||||
```bash
|
||||
# терминал 1
|
||||
cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start
|
||||
|
||||
# терминал 2
|
||||
cd ma7engine/frontend && npm i && npm run dev
|
||||
```
|
||||
|
||||
Прод-режим — одним процессом, без Vite:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
cd backend && npm start # :8080 отдаёт и API, и собранный фронт
|
||||
```
|
||||
|
||||
Ядро в шаблон приезжает копией, как в любую панель: `npm run update` кладёт
|
||||
`core/browser` в `frontend/src/core`, а `core/server` — в `backend/core`. Руками
|
||||
его звать не нужно, он вызывается сам перед `npm run dev` и `npm run build`,
|
||||
поэтому правка в ядре подхватывается следующим запуском.
|
||||
|
||||
**Что может пойти не так**
|
||||
|
||||
| симптом | причина |
|
||||
|---|---|
|
||||
| `пароль панели не задан` при старте | не передан `ADMIN_PASS` — так и задумано, fail-closed |
|
||||
| `не задан секрет подписи сессий` | не передан `ADMIN_JWT_SECRET` |
|
||||
| `в frontend/dist нет index.html` | фронт не собран. Для дева это норма — открывай `:5173` |
|
||||
| порт занят | `PORT=8081 npm start` и `VITE_PROXY_TARGET=http://localhost:8081 npm run dev` |
|
||||
| `Could not read package.json` | команда запущена не из `ma7engine`, а из каталога уровнем выше |
|
||||
| логин отвечает 429 | сработал рейт-лимит: 5 неудачных попыток с IP за 15 минут. Перезапусти бэкенд — счётчик в памяти |
|
||||
|
||||
|
||||
Четыре вкладки: **«Инструкция»** — как устроен код панели: что где лежит, как
|
||||
добавить вкладку, что откуда импортировать, скелет вкладки и маршрута;
|
||||
**«Кто я»** — без `permission`, видна всем залогиненным, ходит в
|
||||
`/api/demo/whoami`; **«Секреты»** — закрыта правом `secrets.view`; **«Витрина»** —
|
||||
живой справочник дизайн-системы: все классы публичного контракта с подписями.
|
||||
Первая и последняя ничего не грузят и открываются без бэкенда. Переименовал
|
||||
класс в ядре — увидел это на витрине.
|
||||
|
||||
Заодно показано: свой класс в `App.css` и как (не) трогать палитру, расширение
|
||||
карты иконок (`{ ...coreIcons, star: IcoStar }`), проброс ошибки вышестоящего API.
|
||||
Чтобы увидеть гейт по правам в работе, задай в `backend/server.ts`
|
||||
`claims: { permissions: [] }` — вкладка «Секреты» исчезнет из меню, а её маршрут
|
||||
начнёт отвечать 403.
|
||||
|
||||
### Как из него сделать свою панель
|
||||
|
||||
Одной командой:
|
||||
|
||||
```sh
|
||||
npm run new-panel -- admin-hub # → ../admin-hub, соседом ma7engine
|
||||
npm run new-panel -- admin-hub ~/work/hub # явный путь
|
||||
```
|
||||
|
||||
Панель — надстройка: она лежит подкаталогом продуктового репозитория
|
||||
(`ghosthub/admin`, `awg-control/admin`), а не самостоятельным проектом.
|
||||
Скаффолдер копирует **`frontend/` и `backend/`**, но не `core/`: ядро приходит
|
||||
отдельно, через `git subtree` — см. следующий раздел.
|
||||
|
||||
Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось:
|
||||
|
||||
1. **Имена** в `package.json` обеих половин.
|
||||
2. **Брендинг и ключи `localStorage`** в `App.tsx` — `admin-hub` даёт
|
||||
`admin_hub_token` и `admin_hub_theme`. Забытые ключи не абстрактны: две
|
||||
панели на одном домене затирают друг другу токен и тему.
|
||||
3. **Демо-часть**: вкладки `hello` и `secrets` и маршруты `/api/demo/*` уезжают,
|
||||
`mountRoutes` остаётся пустым с образцом в комментарии. `docs` и `kit`
|
||||
остаются — обе самодостаточны, и панель запускается непустой.
|
||||
4. **`.gitignore`**, `Dockerfile`, `.dockerignore` и `scripts/dev.sh`.
|
||||
|
||||
Остаётся только **учётка и секрет** — `ADMIN_USER`, `ADMIN_PASS` (или
|
||||
`ADMIN_PASS_HASH`), `ADMIN_JWT_SECRET` в окружении бэкенда; без них ядро не даст
|
||||
процессу подняться.
|
||||
|
||||
### Ядро в продукт — `git subtree`
|
||||
|
||||
У ядра две половины и два адресата, а `git subtree` работает с одним `prefix` за
|
||||
раз, поэтому каждая половина выделяется в свою производную ветку:
|
||||
|
||||
```sh
|
||||
npm run release # core/browser → dist/browser, core/server → dist/server
|
||||
```
|
||||
|
||||
В репозитории продукта, где панель лежит подкаталогом `admin/`:
|
||||
|
||||
```sh
|
||||
git subtree add --prefix=admin/frontend/src/core <ядро> dist/browser --squash
|
||||
git subtree add --prefix=admin/backend/core <ядро> dist/server --squash
|
||||
|
||||
git subtree pull --prefix=admin/frontend/src/core <ядро> dist/browser --squash
|
||||
git subtree pull --prefix=admin/backend/core <ядро> dist/server --squash
|
||||
```
|
||||
|
||||
`<ядро>` — путь к ma7engine либо URL его репозитория.
|
||||
|
||||
**Почему поддерево, а не копирование.** `update.sh` сносил каталог целиком
|
||||
(`rm -rf` + `cp -R`): локальная правка ядра в продукте исчезала молча, истории
|
||||
не оставалось, и «панель разошлась с ядром» выяснялось постфактум. `git subtree`
|
||||
делает настоящий merge — встречные правки одной строки дают конфликт **в момент
|
||||
обновления**, а правку, сделанную в продукте, можно вернуть в ядро
|
||||
(`git subtree push`). Ядро при этом лежит в репозитории продукта закоммиченным,
|
||||
поэтому образ собирается из чистого checkout'а, без ma7engine рядом.
|
||||
|
||||
Ветки `dist/*` полностью производные: руками не правятся и не мержатся обратно —
|
||||
правится `core/`, после чего `npm run release` гоняется снова.
|
||||
|
||||
**В самом ma7engine** ядро по-прежнему раскладывается копией (`npm run update`
|
||||
из соседнего `core/`) — там источник лежит в том же репозитории, и поддерево
|
||||
было бы поддеревом самого себя. Доктор различает эти два уклада и проверяет
|
||||
каждый по своим правилам; в панели он ругается на оставшийся `update.sh`, потому
|
||||
что тот затёр бы поддерево.
|
||||
|
||||
### Проверка соответствия — `npm run doctor`
|
||||
|
||||
Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она
|
||||
ему до сих пор:
|
||||
|
||||
```sh
|
||||
cd <панель>/frontend && npm run doctor
|
||||
node scripts/doctor.mjs ../admin-hub # из ma7engine, по чужому пути
|
||||
```
|
||||
|
||||
**Ошибки** (выход 1) — то, из-за чего панель соберётся неправильно или не
|
||||
соберётся вовсе: пропал `update.sh` или `predev`/`prebuild`; разъехались алиасы
|
||||
`@core` в `tsconfig.json` и `vite.config.ts`; `core` не закрыт `.gitignore` или,
|
||||
наоборот, исключён `.dockerignore` (тогда образ соберётся без ядра); у вкладки
|
||||
нет `metadata.json`, дублируется `id`, иконка не в карте `ICONS`; в бэкенде нет
|
||||
`express` или `jsonwebtoken`.
|
||||
|
||||
Главная проверка — **сверка вендоренной копии с ядром по хэшу дерева**: она
|
||||
ловит правку `src/core/` руками, то есть ровно ту первопричину, из-за которой
|
||||
три панели когда-то и разъехались. Ядро ищется тем же перебором, что в
|
||||
`update.sh`; не найдено — проверка честно печатается как пропущенная.
|
||||
|
||||
**Предупреждения** (выход 0, чтобы косметика не ломала CI) — следы шаблона:
|
||||
`demo_token`, `Demo Panel`, имена `ma7engine-example-*`, разъехавшиеся между
|
||||
половинами `typescript` и `@types/node`. На самом шаблоне они подавлены.
|
||||
|
||||
Доктор едет в панель вместе с ядром — его кладёт `update.sh` рядом с собой, так
|
||||
что проверки в панели всегда те же, что в ядре.
|
||||
|
||||
## Подключение
|
||||
|
||||
Это про панель, которая уже есть; новая начинается с шаблона выше.
|
||||
|
||||
Ядро вендорится в `src/core` потребителя и подключается алиасом. Копия, а не
|
||||
алиас на соседнюю папку, потому что контекст сборки образа панели — её
|
||||
собственный каталог (`COPY frontend/ ./`), и путь выше него внутри контейнера не
|
||||
разрешится. При копии `./src/core` одинаков в деве, в `tsc --noEmit` и в Docker,
|
||||
и Dockerfile трогать не нужно.
|
||||
|
||||
```bash
|
||||
cp <путь>/ma7engine/scripts/update.sh admin/frontend/scripts/
|
||||
cd admin/frontend && npm run update # или sh scripts/update.sh
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// package.json — обновление руками и перед каждым дев-запуском и сборкой
|
||||
"scripts": {
|
||||
"update": "sh scripts/update.sh",
|
||||
"predev": "npm run update",
|
||||
"prebuild": "npm run update"
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// tsconfig.json
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["./src/core/*"], // ядро
|
||||
"@/*": ["./src/*"] // свой код панели
|
||||
}
|
||||
```
|
||||
```ts
|
||||
// vite.config.ts
|
||||
resolve: { alias: {
|
||||
'@core': path.resolve(__dirname, 'src/core'),
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
} }
|
||||
```
|
||||
|
||||
Второй алиас — не косметика: вкладка объявлена самодостаточной папкой, но с
|
||||
`../../lib/shared` внутри она не переносится между панелями и ломается при
|
||||
изменении вложенности. Конфликта между `@` и `@core` нет ни в Vite, ни в
|
||||
TypeScript: rollup-алиас матчит строку как `id === key || id.startsWith(key + '/')`,
|
||||
а `paths` выбирает самый длинный подходящий префикс.
|
||||
|
||||
**Внутри самого ядра алиасов быть не должно** — оно импортит себя относительными
|
||||
путями (`./session`, `../auth`). Иначе вендоренная копия потянет за собой
|
||||
требование к настройке потребителя, и «скопировал папку — работает» перестанет
|
||||
быть правдой.
|
||||
|
||||
`src/core/` (и `backend/core/`) — в `.gitignore`, но **не** в `.dockerignore`: в
|
||||
образ они должны приехать вместе с остальными исходниками. Путь к ядру берётся
|
||||
из `$MA7ENGINE`, иначе перебором кандидатов от ближнего к дальнему — ближний
|
||||
выигрывает, чтобы панель не утащила молча старую копию из каталога уровнем выше.
|
||||
Выбранный путь печатается.
|
||||
|
||||
Серверная половина кладётся в `<панель>/backend/core`. Панель из одной папки, где
|
||||
сервер лежит рядом с фронтом (`server.ts` в корне, как в `awg-ui`), получает её в
|
||||
`<панель>/core`.
|
||||
|
||||
В контейнере ядра рядом нет — и это норма: `update.sh` видит уже
|
||||
синхронизированную копию, печатает это и выходит с нулём, поэтому `prebuild`
|
||||
внутри образа не падает.
|
||||
|
||||
Позже это меняется на настоящую зависимость без правки импортов: спецификаторы
|
||||
`@core/...` остаются, меняется только строка резолва.
|
||||
|
||||
## Точка сборки панели
|
||||
|
||||
```tsx
|
||||
import Panel, { type PanelConfig } from '@core/shell';
|
||||
import './App.css'; // локальные стили — после стилей ядра
|
||||
import * as tabs from './tabs';
|
||||
|
||||
const config: PanelConfig = {
|
||||
tabs,
|
||||
branding: { name: 'MA7 Admin', version: '0.1.0' },
|
||||
storageKeys: { token: 'admin_token', theme: 'admin_theme' },
|
||||
// loadSession — по умолчанию GET /api/session; панель без ролей отдаёт
|
||||
// отсюда permissions: ['*']
|
||||
// slots — свой хром: aboveTab (над вкладкой), headerExtra (в углу шапки)
|
||||
};
|
||||
|
||||
export default () => <Panel config={config} />;
|
||||
```
|
||||
|
||||
## Реестр вкладок
|
||||
|
||||
`import.meta.glob` обязан остаться в приложении: это compile-time фича Vite, её
|
||||
паттерн резолвится относительно файла, где написан, — уехав в ядро, он сканировал
|
||||
бы ядро, а не `src/tabs/` приложения. Поэтому в приложении остаётся 8 строк:
|
||||
|
||||
```ts
|
||||
import { buildTabs } from '@core/tabs';
|
||||
import { ICONS } from '../components/icons';
|
||||
|
||||
export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
|
||||
import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }),
|
||||
import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }),
|
||||
ICONS,
|
||||
);
|
||||
```
|
||||
|
||||
`metadata.json` компилятором не проверяется, поэтому валидируется в рантайме —
|
||||
кривой манифест падает с внятной ошибкой при сборке `TABS`, а не тихо в UI:
|
||||
|
||||
| поле | обяз. | смысл |
|
||||
|---|---|---|
|
||||
| `id` | да | ключ активной вкладки, должен быть уникален |
|
||||
| `label` | да | подпись в меню |
|
||||
| `icon` | да | имя в карте `ICONS` |
|
||||
| `category` | нет | группа в меню; без неё — плоский список |
|
||||
| `permission` | нет | право доступа; без него видна всем залогиненным |
|
||||
| `order` | нет | порядок по возрастанию, по умолчанию 0 |
|
||||
| `devOnly` | нет | вырезается из prod-сборки (`import.meta.env.PROD`) |
|
||||
|
||||
## Иконки
|
||||
|
||||
`coreIcons` — только иконки **вкладок** (`key`, `globe`). Хром оболочки
|
||||
(`IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`) — именованные экспорты, через карту
|
||||
не ищется. Приложение расширяет и перекрывает: `{ ...coreIcons, ...myIcons }`.
|
||||
|
||||
## Контракт CSS-классов
|
||||
|
||||
Имена классов из `core/browser/shell/styles.css` — публичный API: на них опирается код вкладок
|
||||
во всех панелях, поэтому переименование любого из них ломающее. Полный список — в
|
||||
шапке самого файла. Коротко: `.btn` с вариантами, `.field`, `.table-card`,
|
||||
`.chip`, `.toolbar`, `.modal-*`, `.actions`, `.empty`, `.td-mono`, `.td-num`.
|
||||
|
||||
Цвета — только через токены `var(--…)`; палитра задаётся на `:root` и
|
||||
переопределяется на `:root[data-theme="dark"]`.
|
||||
|
||||
## Что в ядро не идёт
|
||||
|
||||
Брендинг (это поле конфига), карта `ICONS` целиком, продуктовые типы и утилиты,
|
||||
вкладки, бэкенды. Компонент, нужный **одной** панели, остаётся в ней: обобщать по
|
||||
одному потребителю рано. Понадобился второй — тогда и переезжает.
|
||||
|
||||
## Сервер
|
||||
|
||||
```ts
|
||||
import { createPanelServer } from './core';
|
||||
|
||||
const app = createPanelServer({
|
||||
auth: {
|
||||
user: process.env.ADMIN_USER ?? 'admin',
|
||||
password: process.env.ADMIN_PASS, // либо passwordHash
|
||||
secret: process.env.ADMIN_JWT_SECRET ?? '',
|
||||
revocable: true, // логаут реально отзывает токен
|
||||
},
|
||||
staticDir: '../frontend/dist',
|
||||
mountRoutes(app, { auth, forwardError }) {
|
||||
app.get('/api/x', auth.requireAuth, auth.requirePermission('x.view'), handler);
|
||||
},
|
||||
});
|
||||
app.listen(8080);
|
||||
```
|
||||
|
||||
Порядок монтирования фиксирован внутри и не может быть нарушен снаружи:
|
||||
`json` → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
|
||||
любой GET, поэтому маршрут, зарегистрированный после него, не сработал бы.
|
||||
|
||||
Панель с готовым бэкендом может брать модули по отдельности
|
||||
(`createSessionStore`, `createAuth`, `createLoginRateLimit`, `serveSpa`) — так
|
||||
подключён MA7, у которого 38 своих маршрутов уже были написаны.
|
||||
|
||||
### Авторизация из коробки, и панель-прокси
|
||||
|
||||
`createPanelServer` всегда собирается с авторизацией: учётка задаётся в
|
||||
`auth: { user, password | passwordHash, secret }`, панель сама выпускает и
|
||||
проверяет токен, `/login`, `/logout` и `/api/session` монтируются готовыми.
|
||||
Своей авторизации панель не пишет, а `sessions` и `auth` в контексте
|
||||
гарантированы — проверять их на существование не нужно.
|
||||
|
||||
Панель, у которой собственных учёток нет (логин форвардится наверх, где живут
|
||||
настоящие учётки), `createPanelServer` не использует — она собирает express
|
||||
сама из кусков ядра:
|
||||
|
||||
```ts
|
||||
const app = express();
|
||||
const rateLimit = createLoginRateLimit();
|
||||
|
||||
// Тело JSON не парсим: прокси должен переслать сырой поток запроса как есть.
|
||||
app.post('/login', rateLimit.middleware, createProxyMiddleware({ … }));
|
||||
// …остальные прокси-маршруты…
|
||||
|
||||
serveSpa(app, STATIC_DIR); // последним: fallback ловит любой GET
|
||||
```
|
||||
|
||||
Рейт-лимит и раздачу фронта наверх не вынести: вышестоящий API не видит IP
|
||||
клиента панели отдельно от IP самого прокси.
|
||||
|
||||
### Что ядро делает строже панелей
|
||||
|
||||
- **Пароль сравнивается за постоянное время** (SHA-256 + `timingSafeEqual`), а
|
||||
не через `!==`. Поддержан `scrypt`-хэш — тогда открытого пароля в окружении
|
||||
нет вовсе, `hashPassword()` его генерирует.
|
||||
- **Пароль проверяется даже при несовпавшем логине** — иначе по времени ответа
|
||||
отличается «нет такого пользователя» от «неверный пароль».
|
||||
- **Fail-closed при старте.** Без пароля или без секрета подписи процесс не
|
||||
поднимается. Панель, тихо работающая с паролем `admin`, — худший исход, чем
|
||||
не стартовавшая панель.
|
||||
- **Рейт-лимит всегда разбирает `X-Forwarded-For`** (в проде; в деве заголовку
|
||||
доверять нельзя). Панель, бравшая `req.socket.remoteAddress` напрямую, за
|
||||
nginx схлопывала всех клиентов в один ключ, и пять неудачных попыток кем
|
||||
угодно блокировали вход всем.
|
||||
- **Неизвестный `/api/*` отдаёт JSON-404**, а не `index.html` со статусом 200,
|
||||
на котором клиент падал бы при разборе ответа.
|
||||
|
||||
### Ограничения, о которых надо знать
|
||||
|
||||
Счётчик попыток и список отозванных токенов — в памяти процесса. Они не
|
||||
переживают рестарт и не общие для нескольких инстансов за балансировщиком.
|
||||
Для защиты от перебора этого достаточно; для настоящего отзыва на нескольких
|
||||
инстансах передай своё `revocationStore`.
|
||||
|
||||
`jsonwebtoken` и `express` — peer-зависимости, ставятся в бэкенде панели.
|
||||
|
||||
## Кто на нём сидит
|
||||
|
||||
Три панели, ради которых ядро и появилось. Полезно как три разных ответа на
|
||||
вопрос «сколько своего остаётся».
|
||||
|
||||
**admin-ma7** — панель с правами и своим бэкендом. Фронт: `App.tsx` — 18 строк
|
||||
конфига (было 232), реестр вкладок — 20 (было 105), `App.css` — 52 строки
|
||||
плиток дашборда (было 612, остальное нашлось в ядре). Бэкенд собран
|
||||
`createPanelServer`, все 38 маршрутов ушли в `mountRoutes`; своя обёртка над
|
||||
`forwardError` — только ради текста «API MA7 недоступен».
|
||||
|
||||
**admin-hub** — панель с правами, логин делегирует наверх. Фронт: `App.tsx` —
|
||||
23 строки конфига, реестр — 23, `App.css` **удалён**: все 88 его правил
|
||||
побайтово совпали с дизайн-системой ядра. Бэкенд берёт `createLoginRateLimit` и
|
||||
`serveSpa` отдельными модулями, а не через `createPanelServer`: своей
|
||||
авторизации у него нет вовсе, и тянуть ради неё `jsonwebtoken` незачем.
|
||||
|
||||
**awg-ui** — панель без ролей, живёт одной папкой (сервер рядом с фронтом).
|
||||
Ролей нет: `loadSession` ходит в `/health` — это одновременно проверка токена и
|
||||
данные для сервер-бара — и отдаёт `permissions: ['*']`. Свой хром — в слотах:
|
||||
сервер-бар в `aboveTab`, кнопка перезапуска AWG в `headerExtra`. Своих стилей —
|
||||
29 правил (было 700 строк). Сервер собран `createPanelServer`; ключи внешнего
|
||||
API в SQLite и прокси к `awg-ctrl` остались своими.
|
||||
Generated
-1629
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "ma7engine-example-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "tsx server.ts",
|
||||
"dev": "tsx watch server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"tsx": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^26.3.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
|
||||
// ../frontend; ядро в свою панель приезжает через `npm run update`.
|
||||
//
|
||||
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
|
||||
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.
|
||||
//
|
||||
// Запуск:
|
||||
// ADMIN_PASS=... ADMIN_JWT_SECRET=... npm start
|
||||
//
|
||||
// Панель-прокси, у которой своих учёток нет (авторизация живёт в основном
|
||||
// продукте), createPanelServer не использует и собирает express сама из кусков
|
||||
// ядра — см. раздел про панель-прокси в README.
|
||||
import path from 'node:path';
|
||||
import type { Request, Response } from 'express';
|
||||
// Ядро — вендоренная копия в ./core: её кладёт `npm run update`, запущенный во
|
||||
// фронте (он же predev/prebuild). Поэтому express и jsonwebtoken резолвятся из
|
||||
// backend/node_modules, как в любой панели.
|
||||
import { createPanelServer } from './core';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
|
||||
const app = createPanelServer({
|
||||
auth: {
|
||||
user: process.env.ADMIN_USER ?? 'admin',
|
||||
password: process.env.ADMIN_PASS,
|
||||
passwordHash: process.env.ADMIN_PASS_HASH,
|
||||
secret: process.env.ADMIN_JWT_SECRET ?? '',
|
||||
ttl: '12h',
|
||||
revocable: true,
|
||||
// claims не задан — вошедший получает permissions: ['*'], то есть
|
||||
// видит все вкладки. Появятся учётки с разным доступом — размечать
|
||||
// здесь.
|
||||
},
|
||||
|
||||
// Раскладка как в панелях: <панель>/backend рядом с <панель>/frontend.
|
||||
staticDir: process.env.STATIC_DIR ?? path.join(__dirname, '..', 'frontend', 'dist'),
|
||||
|
||||
mountRoutes(app, { auth, forwardError }) {
|
||||
const { requireAuth, requirePermission, getSession } = auth;
|
||||
|
||||
// Открыт любому залогиненному — у вкладки в metadata.json нет permission.
|
||||
app.get('/api/demo/whoami', requireAuth, (req: Request, res: Response) => {
|
||||
res.json(getSession(req));
|
||||
});
|
||||
|
||||
// Закрыт правом. Гейт на фронте (permission во вкладке) — это только
|
||||
// UX; настоящая граница здесь.
|
||||
app.get('/api/demo/secrets', requireAuth, requirePermission('secrets.view'),
|
||||
(_req: Request, res: Response) => {
|
||||
res.json({ items: ['первый', 'второй'] });
|
||||
});
|
||||
|
||||
// Поход в вышестоящий API: его статус и текст пробрасываются клиенту,
|
||||
// а не превращаются в 500.
|
||||
app.get('/api/demo/upstream', requireAuth, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
throw { response: { status: 404, data: { message: 'Не найдено наверху' } } };
|
||||
} catch (e) { forwardError(res, e); }
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`demo-panel: listening on :${PORT}`));
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["server.ts"]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Слой сессии и прав. Опционален: панель без учёток и ролей (плоский список
|
||||
// вкладок, доступных всем) обходится BasePageProps и в этот модуль не заходит.
|
||||
|
||||
// "*" в permissions — владелец, доступ ко всему в обход остальных проверок.
|
||||
export const OWNER_PERMISSION = '*';
|
||||
|
||||
export function hasPermission(permissions: string[], perm: string): boolean {
|
||||
return permissions.includes(OWNER_PERMISSION) || permissions.includes(perm);
|
||||
}
|
||||
|
||||
// Что оболочка узнаёт о вошедшем. Отдаётся эндпоинтом сессии, адрес и форма
|
||||
// ответа которого задаются в конфиге панели (loadSession).
|
||||
export interface Session {
|
||||
user: string | null;
|
||||
userId: string | null;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_SESSION: Session = { user: null, userId: null, role: '', permissions: [] };
|
||||
|
||||
// Пропсы вкладки в панели без прав: вкладке нужен только токен и снэкбар.
|
||||
export interface BasePageProps {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
}
|
||||
|
||||
// Пропсы вкладки в панели с сессией и правами.
|
||||
export interface SessionPageProps extends BasePageProps {
|
||||
username: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
// Все permission-строки, встречающиеся у вкладок — нужно только тем вкладкам,
|
||||
// которые сами выдают права; остальные это поле игнорируют.
|
||||
availablePermissions: string[];
|
||||
// Переключить активную вкладку по её id из metadata.json. Роутера в панели
|
||||
// нет — активная вкладка это состояние оболочки.
|
||||
onNavigate: (tabId: string) => void;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Единственная точка выхода вкладок в сеть. Вкладка не создаёт свой axios и не
|
||||
// собирает заголовок авторизации руками — иначе обработка протухшего токена
|
||||
// разъезжается по вкладкам.
|
||||
import axios from 'axios';
|
||||
|
||||
// Что делать, когда сервер ответил 401. Ставит оболочка при старте: токен
|
||||
// протух или отозван — надо разлогинить, а не показывать ошибку в снэкбаре.
|
||||
// Само ядро про localStorage не знает — ключ хранения принадлежит приложению.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||
try {
|
||||
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
||||
return r.data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Бэкенды панелей отдают ошибку по-разному: одни как {error}, другие как
|
||||
// {message}. Вкладки читают через этот хелпер, а не разбирают форму ответа
|
||||
// в каждом catch.
|
||||
export function errText(e: unknown, fallback = 'Сервер недоступен'): string {
|
||||
const data = (e as { response?: { data?: { error?: string; message?: string } } })?.response?.data;
|
||||
return data?.error ?? data?.message ?? fallback;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Иконки ядра — только те, что уже совпадали побайтово в панелях-потребителях.
|
||||
// Продуктовые иконки живут в приложении; карта ICONS собирается там же:
|
||||
// export const ICONS = { ...coreIcons, ...myIcons };
|
||||
// Все иконки — инлайновый SVG на currentColor, без внешних зависимостей.
|
||||
|
||||
export const IcoMenu = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoLogout = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSun = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoMoon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoPlus = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoTrash = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoRefresh = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoCopy = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoGlobe = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoKey = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5"/>
|
||||
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Карта иконок ВКЛАДОК — по ней реестр резолвит поле "icon" из metadata.json.
|
||||
// Сюда попадают только иконки, осмысленные как иконка раздела; хром оболочки
|
||||
// (menu/logout/sun/moon) оболочка импортирует именованными экспортами и через
|
||||
// карту никогда не ищет.
|
||||
//
|
||||
// Приложение расширяет карту своими и при совпадении ключа перекрывает:
|
||||
// export const ICONS = { ...coreIcons, ...myIcons };
|
||||
export const coreIcons: Record<string, () => JSX.Element> = {
|
||||
key: IcoKey,
|
||||
globe: IcoGlobe,
|
||||
};
|
||||
@@ -1,301 +0,0 @@
|
||||
// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок.
|
||||
// Про конкретные вкладки не знает ничего — их приносит реестр из конфига.
|
||||
//
|
||||
// Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage,
|
||||
// адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель
|
||||
// без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот
|
||||
// же код, отличаются только конфигом.
|
||||
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import './styles.css';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons';
|
||||
import { setUnauthorizedHandler } from '../http';
|
||||
import { EMPTY_SESSION, type Session } from '../auth';
|
||||
import type { BuiltTabs, TabDef } from '../tabs';
|
||||
|
||||
// Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть
|
||||
// и показывать сообщения теми же средствами, что и вкладки.
|
||||
export interface SlotContext {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
session: Session;
|
||||
tab: TabDef | null;
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
// Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда,
|
||||
// а не импортируется, потому что import.meta.glob обязан остаться в
|
||||
// приложении — см. комментарий в tabs/index.ts.
|
||||
tabs: BuiltTabs;
|
||||
branding: { name: string; version?: string };
|
||||
// Ключи localStorage. Разводятся по панелям, чтобы две панели на одном
|
||||
// домене не затирали друг другу токен и тему.
|
||||
storageKeys?: { token?: string; theme?: string };
|
||||
endpoints?: { login?: string; logout?: string };
|
||||
// Как узнать, жив ли токен, и получить роль с правами. По умолчанию —
|
||||
// GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*'].
|
||||
loadSession?: (token: string) => Promise<Session>;
|
||||
// Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и
|
||||
// может вернуть null там, где рисоваться не должен.
|
||||
slots?: {
|
||||
aboveTab?: (ctx: SlotContext) => ReactNode;
|
||||
headerExtra?: (ctx: SlotContext) => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Panel({ config }: { config: PanelConfig }) {
|
||||
const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs;
|
||||
const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token';
|
||||
const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme';
|
||||
const LOGIN_URL = config.endpoints?.login ?? '/login';
|
||||
const LOGOUT_URL = config.endpoints?.logout ?? '/logout';
|
||||
const APP_NAME = config.branding.name;
|
||||
const APP_VERSION = config.branding.version;
|
||||
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [session, setSession] = useState<Session>(EMPTY_SESSION);
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||
const touchStartX = useRef(0);
|
||||
const touchCurrentX = useRef(0);
|
||||
|
||||
const showMsg = useCallback((text: string) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}, []);
|
||||
|
||||
// Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется
|
||||
// меню. Данные конкретных вкладок грузят сами вкладки.
|
||||
const loadSession = config.loadSession;
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const data = loadSession
|
||||
? await loadSession(tok)
|
||||
: (await axios.get<Session>('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data;
|
||||
setSession(data);
|
||||
return data;
|
||||
}, [loadSession]);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass });
|
||||
const tok = auth.token as string;
|
||||
await startSession(tok);
|
||||
localStorage.setItem(TOKEN_KEY, tok);
|
||||
setLoginError('');
|
||||
setToken(tok);
|
||||
} catch (e) {
|
||||
// Все три причины отказа пишем под форму, а не в снэкбар: тот живёт
|
||||
// 3 секунды и уезжает, а человек в этот момент смотрит на поля и
|
||||
// перенабирает пароль. Особенно важно для 429 — там верный пароль
|
||||
// тоже не пустит, и без текста это выглядит как «пароль не тот».
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
||||
setLoginError('Неверный логин или пароль');
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||
setLoginError(e.response.data?.error ?? 'Слишком много попыток');
|
||||
} else {
|
||||
setLoginError('Сервер недоступен');
|
||||
}
|
||||
}
|
||||
}, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { /* токен уже мог истечь */ }
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setSession(EMPTY_SESSION);
|
||||
setActiveTab('');
|
||||
setDrawerOpen(false);
|
||||
}, [token, LOGOUT_URL, TOKEN_KEY]);
|
||||
|
||||
// Любой 401 из apiFetch вкладки означает, что токен протух или отозван —
|
||||
// разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока
|
||||
// пользователь сам догадается перезайти.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setSession(EMPTY_SESSION);
|
||||
setActiveTab('');
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [TOKEN_KEY]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Восстановить сессию из сохранённого токена
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) {
|
||||
startSession(saved).catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
});
|
||||
}
|
||||
}, [startSession, TOKEN_KEY]);
|
||||
|
||||
// Применять и сохранять тему
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme, THEME_KEY]);
|
||||
|
||||
// Блокировать прокрутку фона когда drawer открыт
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = drawerOpen ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Закрывать drawer по Escape
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Меню строится из вкладок, доступных правам текущей учётки, сгруппированных
|
||||
// по категориям. Вкладок (или прав на них) может не быть вовсе — тогда
|
||||
// вместо контента показываем заглушку.
|
||||
const groups = visibleTabsByCategory(session.permissions);
|
||||
const visible = groups.flatMap(g => g.tabs);
|
||||
const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null;
|
||||
|
||||
// Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке
|
||||
// решает сам слот: активная вкладка лежит в ctx.tab.
|
||||
const slotCtx: SlotContext = { token, showMsg, session, tab };
|
||||
const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<button
|
||||
className="theme-toggle"
|
||||
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
|
||||
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
|
||||
>
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{token && slot('headerExtra')}
|
||||
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<span className="logo-name">{APP_NAME}</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Логин"
|
||||
value={loginUser}
|
||||
onChange={e => setLoginUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={loginPass}
|
||||
onChange={e => setLoginPass(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && login()}
|
||||
/>
|
||||
<button className="btn btn--primary btn--full" onClick={login}>
|
||||
Войти
|
||||
</button>
|
||||
{loginError && <p className="login-error">{loginError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Top bar — видим только на мобильном */}
|
||||
<header className="top-bar">
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<IcoMenu />
|
||||
</button>
|
||||
<span className="top-bar-title">{tab?.label ?? APP_NAME}</span>
|
||||
</header>
|
||||
|
||||
{/* Затемнение под drawer */}
|
||||
<div
|
||||
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="layout">
|
||||
<aside
|
||||
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<span className="logo-name">{APP_NAME}</span>
|
||||
{APP_VERSION && <span className="sidebar-version">{APP_VERSION}</span>}
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{groups.map(g => (
|
||||
<div key={g.category} className="nav-group">
|
||||
{/* Вкладки без category собраны в одну безымянную группу —
|
||||
заголовка у неё нет, меню выглядит плоским списком. */}
|
||||
{g.category && <div className="nav-category">{g.category}</div>}
|
||||
{g.tabs.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<button className="btn btn--danger" onClick={logout}>
|
||||
<IcoLogout /> Выйти
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
|
||||
{slot('aboveTab')}
|
||||
{tab
|
||||
? <tab.Page token={token} showMsg={showMsg}
|
||||
username={session.user ?? ''} userId={session.userId ?? ''}
|
||||
role={session.role} permissions={session.permissions}
|
||||
availablePermissions={ALL_TAB_PERMISSIONS}
|
||||
onNavigate={setActiveTab} />
|
||||
: <p className="placeholder">
|
||||
{TABS.length === 0
|
||||
? <>Вкладок пока нет — добавь папку в <code>src/tabs/</code></>
|
||||
: 'Ни одна вкладка не доступна твоим правам'}
|
||||
</p>}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
/*
|
||||
Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент
|
||||
в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут
|
||||
в её папке (tabs/<name>/<name>.css) и импортятся её же компонентом.
|
||||
|
||||
ПУБЛИЧНЫЙ КОНТРАКТ. На эти имена классов опирается код вкладок во всех
|
||||
панелях-потребителях, поэтому переименование любого из них — ломающее
|
||||
изменение, а не рефакторинг:
|
||||
|
||||
кнопки .btn .btn--primary .btn--tonal .btn--outline .btn--danger
|
||||
.btn--full .btn--sq .btn-icon .btn-icon--danger .btn-icon--neutral
|
||||
формы .field
|
||||
таблица .table-card .td-mono .td-num .td-actions
|
||||
прочее .chip (+--online/--offline/--error) .chip-dot
|
||||
.toolbar .toolbar-btns .actions .empty
|
||||
модалка .modal-backdrop .modal-card .modal-title .modal-actions .modal-hint
|
||||
|
||||
Всё остальное (.app, .sidebar, .drawer-*, .top-bar, .nav-*, .login-*, .snack,
|
||||
.main, .page-title, .placeholder) принадлежит оболочке — вкладки этих имён
|
||||
не используют.
|
||||
|
||||
Живая витрина всех этих классов — вкладка «Витрина» в frontend/ (шаблон панели).
|
||||
|
||||
Палитра задаётся токенами на :root и переопределяется на [data-theme="dark"];
|
||||
цвета в правилах — только через var(--token), литералов в компонентах нет.
|
||||
*/
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
:root {
|
||||
--primary: #6a45b8;
|
||||
--on-primary: #ffffff;
|
||||
--primary-container: #ebddff;
|
||||
--on-primary-container: #5a2da8;
|
||||
|
||||
--surface: #faf6f0;
|
||||
--surface-bright: #fdfaf6;
|
||||
--surface-container: #f0e8dd;
|
||||
--surface-hi: #ece2d3;
|
||||
--on-surface: #3a2e22;
|
||||
|
||||
--outline: #d8cdbe;
|
||||
--outline-variant: #ece2d3;
|
||||
|
||||
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
|
||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
|
||||
/* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */
|
||||
--label: #5a4a3a;
|
||||
|
||||
/* state layers / тени — зависят от темы */
|
||||
--state-primary: rgba(106, 69, 184, .10);
|
||||
--state-row: rgba(106, 69, 184, .035);
|
||||
--state-neutral: rgba(58, 46, 34, .08);
|
||||
--scrim: rgba(58, 46, 34, .45);
|
||||
--shadow: rgba(58, 46, 34, .15);
|
||||
|
||||
--font: system-ui, -apple-system, 'Inter', sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
|
||||
--r-pill: 100px;
|
||||
--r-card: 24px;
|
||||
--r-table: 16px;
|
||||
--r-logo: 9px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--primary: #cbb3ff;
|
||||
--on-primary: #381b6c;
|
||||
--primary-container: #52389e;
|
||||
--on-primary-container: #ebddff;
|
||||
|
||||
--surface: #1a1611;
|
||||
--surface-bright: #2b251d;
|
||||
--surface-container: #251f18;
|
||||
--surface-hi: #302820;
|
||||
--on-surface: #ede2d4;
|
||||
|
||||
--outline: #4d4334;
|
||||
--outline-variant: #342c20;
|
||||
|
||||
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
|
||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
|
||||
--label: #b6a994;
|
||||
|
||||
--state-primary: rgba(203, 179, 255, .14);
|
||||
--state-row: rgba(203, 179, 255, .05);
|
||||
--state-neutral: rgba(237, 226, 212, .08);
|
||||
--scrim: rgba(0, 0, 0, .55);
|
||||
--shadow: rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; }
|
||||
|
||||
.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
.login-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
.login-card {
|
||||
width: 352px;
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 36px 32px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.login-sub {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-error {
|
||||
font-size: 13px;
|
||||
color: var(--danger-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px solid var(--outline);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-bright);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.field:focus { border-color: var(--primary); }
|
||||
.field::placeholder { color: var(--neutral-dot); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; }
|
||||
.btn:active { opacity: 0.74; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.btn--primary { background: var(--primary); color: var(--on-primary); }
|
||||
.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; }
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--outline);
|
||||
color: var(--primary);
|
||||
}
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
.btn--full { width: 100%; }
|
||||
.btn--sq { width: 44px; padding: 0; flex-shrink: 0; }
|
||||
|
||||
/* icon-only circular button */
|
||||
.btn-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
transition: background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { background: var(--state-primary); }
|
||||
.btn-icon--danger { color: var(--danger-text); }
|
||||
.btn-icon--danger:hover { background: rgba(179,74,58,.10); }
|
||||
.btn-icon--neutral { color: var(--neutral-text); }
|
||||
.btn-icon--neutral:hover { background: var(--state-neutral); }
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 250;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--outline);
|
||||
background: var(--surface-bright);
|
||||
color: var(--on-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 56px;
|
||||
padding: 0 8px 0 4px;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.hamburger-btn {
|
||||
width: 48px; height: 48px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface);
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hamburger-btn:active { background: var(--state-neutral); }
|
||||
.top-bar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
.drawer-scrim--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 0 8px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sidebar-version {
|
||||
font-size: 11px;
|
||||
color: var(--neutral-dot);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.drawer-divider {
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--outline);
|
||||
margin: 0 -12px 8px;
|
||||
}
|
||||
|
||||
.nav-group + .nav-group { margin-top: 8px; }
|
||||
.nav-category {
|
||||
padding: 12px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neutral-dot);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--label);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item--active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip--online { background: var(--success-bg); color: var(--success-text); }
|
||||
.chip--online .chip-dot { background: var(--success-dot); }
|
||||
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||
.chip--offline .chip-dot { background: var(--neutral-dot); }
|
||||
.chip--error { background: var(--error-bg); color: var(--error-text); }
|
||||
.chip--error .chip-dot { background: var(--error-dot); }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
/* Заглушка на месте контента — когда вкладок ещё нет */
|
||||
.placeholder {
|
||||
color: var(--neutral-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.placeholder code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--surface-container);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Тултип без задержки: нативный title браузер показывает через ~500 мс */
|
||||
.tip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.tip-wrap::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tip-wrap:hover::after { opacity: 1; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar .field {
|
||||
width: 224px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* На десктопе кнопки участвуют в flex toolbar'а напрямую */
|
||||
.toolbar-btns { display: contents; }
|
||||
|
||||
.table-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-table);
|
||||
border: 1px solid var(--outline-variant);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: var(--surface-container);
|
||||
color: var(--label);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
thead th:first-child { padding-left: 20px; }
|
||||
thead th:last-child { padding-right: 20px; }
|
||||
|
||||
tbody tr { border-top: 0.5px solid var(--outline-variant); }
|
||||
tbody tr:hover { background: var(--state-row); }
|
||||
|
||||
tbody td {
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody td:first-child { padding-left: 20px; }
|
||||
tbody td:last-child { padding-right: 20px; }
|
||||
|
||||
.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; }
|
||||
.td-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.td-actions { width: 96px; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--neutral-text);
|
||||
padding: 48px 0 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: scale(.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--scrim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 300;
|
||||
animation: backdrop-in 0.18s ease;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 28px 28px 24px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
width: 320px;
|
||||
animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1);
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
|
||||
.modal-actions { display: flex; gap: 8px; width: 100%; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
.snack {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 13px;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--r-pill);
|
||||
z-index: 500;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 16px var(--shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Top bar */
|
||||
.top-bar { display: flex; }
|
||||
.page-title { display: none; }
|
||||
|
||||
/* Drawer scrim */
|
||||
.drawer-scrim { display: block; }
|
||||
|
||||
/* Запретить горизонтальный скролл страницы */
|
||||
body, .app { overflow-x: hidden; }
|
||||
|
||||
/* Sidebar → модальный drawer */
|
||||
.layout {
|
||||
min-height: 0; /* flex: 1 на .app справится сам */
|
||||
overflow: visible;
|
||||
}
|
||||
/* main — не скролл-контейнер, пусть скроллит body */
|
||||
.main { overflow: visible; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 280px;
|
||||
z-index: 400;
|
||||
border-radius: 0 var(--r-card) var(--r-card) 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-shadow: 4px 0 24px var(--shadow);
|
||||
padding-top: 36px;
|
||||
}
|
||||
.sidebar--open { transform: translateX(0); }
|
||||
|
||||
/* Main: одна колонка, поля 16px */
|
||||
.main {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Toolbar → вертикально */
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar .field { width: 100%; }
|
||||
.toolbar-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-btns .btn { flex: 1; }
|
||||
|
||||
/* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */
|
||||
.table-card { display: none; }
|
||||
|
||||
/* Модалка — по ширине экрана */
|
||||
.modal-card {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 360px;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <name>.css — стили вкладки (импортит сам компонент)
|
||||
// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? }
|
||||
//
|
||||
// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда
|
||||
// аргументами — это compile-time фича Vite, её паттерн резолвится относительно
|
||||
// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не
|
||||
// src/tabs/ приложения.
|
||||
//
|
||||
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
|
||||
// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при
|
||||
// сборке TABS, а не «тихо» в UI.
|
||||
import type { ComponentType } from 'react';
|
||||
import { OWNER_PERMISSION } from '../auth';
|
||||
|
||||
// Сериализуемая часть из metadata.json. Обязательны только id, label и icon:
|
||||
// панель без ролей и без группировки меню — базовый случай, а не урезанный.
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // имя иконки → карта ICONS
|
||||
// Группа в меню. Без неё вкладка попадает в единственную безымянную группу,
|
||||
// и сайдбар рисует плоский список.
|
||||
category?: string;
|
||||
// Право, нужное для доступа. Без него вкладка видна любому залогиненному.
|
||||
permission?: string;
|
||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
||||
// Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно
|
||||
// отдаёт 404 независимо от прав, и вкладка вела бы в тупик.
|
||||
devOnly?: boolean;
|
||||
}
|
||||
|
||||
// Собранная вкладка: метадата + резолвнутые Icon и Page.
|
||||
export interface TabDef extends TabMeta {
|
||||
Icon: () => JSX.Element;
|
||||
Page: ComponentType<any>;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
category: string;
|
||||
tabs: TabDef[];
|
||||
}
|
||||
|
||||
export interface BuiltTabs {
|
||||
TABS: TabDef[];
|
||||
ALL_TAB_PERMISSIONS: string[];
|
||||
visibleTabsByCategory: (permissions: string[]) => TabGroup[];
|
||||
}
|
||||
|
||||
type MetaModules = Record<string, { default: unknown }>;
|
||||
type PageModules = Record<string, { default: ComponentType<any> }>;
|
||||
type IconMap = Record<string, () => JSX.Element>;
|
||||
|
||||
// './users/metadata.json' → 'users'
|
||||
const folderOf = (path: string) => path.split('/')[1];
|
||||
|
||||
const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const;
|
||||
const OPTIONAL_STRINGS = ['category', 'permission'] as const;
|
||||
|
||||
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
|
||||
function toMeta(name: string, raw: unknown): TabMeta {
|
||||
const m = (raw ?? {}) as Record<string, unknown>;
|
||||
const fail = (what: string): never => {
|
||||
throw new Error(`tabs/${name}/metadata.json: ${what}`);
|
||||
};
|
||||
const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== '';
|
||||
|
||||
for (const key of REQUIRED_STRINGS) {
|
||||
if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`);
|
||||
}
|
||||
for (const key of OPTIONAL_STRINGS) {
|
||||
if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`);
|
||||
}
|
||||
if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом');
|
||||
if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым');
|
||||
|
||||
return {
|
||||
id: m.id as string,
|
||||
label: m.label as string,
|
||||
icon: m.icon as string,
|
||||
category: m.category as string | undefined,
|
||||
permission: m.permission as string | undefined,
|
||||
order: m.order as number | undefined,
|
||||
devOnly: m.devOnly as boolean | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Собирает реестр вкладок из результатов import.meta.glob приложения:
|
||||
//
|
||||
// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
|
||||
// import.meta.glob('./*/metadata.json', { eager: true }),
|
||||
// import.meta.glob('./*/index.tsx', { eager: true }),
|
||||
// ICONS,
|
||||
// );
|
||||
//
|
||||
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
|
||||
// показывает заглушку вместо контента.
|
||||
export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs {
|
||||
const TABS: TabDef[] = Object.entries(metas)
|
||||
.map(([path, mod]) => {
|
||||
const name = folderOf(path);
|
||||
const meta = toMeta(name, mod.default);
|
||||
const page = pages[`./${name}/index.tsx`]?.default;
|
||||
if (!page) {
|
||||
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
|
||||
}
|
||||
if (!icons[meta.icon]) {
|
||||
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS`);
|
||||
}
|
||||
return { ...meta, Icon: icons[meta.icon], Page: page };
|
||||
})
|
||||
// import.meta.env.PROD — режим сборки этого фронта (vite build), а не
|
||||
// окружения бэкенда, к которому он ходит.
|
||||
.filter(t => !t.devOnly || !import.meta.env.PROD)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||
const ids = TABS.map(t => t.id);
|
||||
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
if (dup) {
|
||||
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
|
||||
}
|
||||
|
||||
// Полный список прав, встречающихся у вкладок — по нему строится форма
|
||||
// выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым
|
||||
// permission — появился и чекбокс, без ручной синхронизации списков.
|
||||
const ALL_TAB_PERMISSIONS = Array.from(
|
||||
new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)),
|
||||
);
|
||||
|
||||
// Вкладки, доступные набору прав, сгруппированные по категориям в порядке
|
||||
// первого появления (сами вкладки уже отсортированы по order). Вкладки без
|
||||
// category собираются в одну безымянную группу — сайдбар рисует её без
|
||||
// заголовка, то есть плоским списком.
|
||||
function visibleTabsByCategory(permissions: string[]): TabGroup[] {
|
||||
const owner = permissions.includes(OWNER_PERMISSION);
|
||||
const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission));
|
||||
const groups: TabGroup[] = [];
|
||||
for (const tab of visible) {
|
||||
const category = tab.category ?? '';
|
||||
let group = groups.find(g => g.category === category);
|
||||
if (!group) { group = { category, tabs: [] }; groups.push(group); }
|
||||
group.tabs.push(tab);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Форматирование и работа с буфером/файлами. Чистые функции без зависимостей —
|
||||
// то, что во всех панелях уже совпадало побайтово.
|
||||
|
||||
// ISO-дата → «2026-08-01 12:34». Без секунд и без часового пояса —
|
||||
// формат для таблиц, где важна разница между записями, а не точность до сек.
|
||||
export function fmtDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return iso.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий)
|
||||
export function timeAgo(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const sec = Math.floor(Date.now() / 1000) - ts;
|
||||
if (sec < 60) return sec + ' сек';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
|
||||
return Math.floor(sec / 3600) + ' ч';
|
||||
}
|
||||
|
||||
export function bytes(n: number | undefined): string {
|
||||
if (!n) return '—';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
|
||||
return (n / 1_073_741_824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
export function downloadFile(filename: string, text: string) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// navigator.clipboard недоступен вне https/localhost — отсюда фолбэк на execCommand.
|
||||
export async function copyText(text: string) {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Demo Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-2117
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "ma7engine-example-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npm run update",
|
||||
"dev": "vite",
|
||||
"update": "sh scripts/update.sh",
|
||||
"prebuild": "npm run update",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"doctor": "node scripts/doctor.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Обновляет вендоренную копию ядра (ma7engine) в панели:
|
||||
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
|
||||
# ma7engine/core/server → <панель>/backend/core (серверная половина)
|
||||
#
|
||||
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
|
||||
# как в awg-ui), получает серверную часть в <панель>/core.
|
||||
#
|
||||
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
|
||||
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
|
||||
# уходящий выше него, внутри контейнера не разрешится. При копии пути
|
||||
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
|
||||
# трогать не нужно вообще.
|
||||
#
|
||||
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
|
||||
# вызывать его нужно и тогда, когда ma7engine рядом нет.
|
||||
#
|
||||
# Использование: npm run update (или sh scripts/update.sh)
|
||||
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
|
||||
# существующий выигрывает, выбранный путь печатается.
|
||||
set -eu
|
||||
|
||||
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
|
||||
|
||||
here=$(abspath "$(dirname -- "$0")")
|
||||
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
|
||||
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
|
||||
|
||||
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
|
||||
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
|
||||
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
|
||||
# молча уводит синхронизацию на старую копию.
|
||||
#
|
||||
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
|
||||
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
|
||||
# именно от неё и уезжает синхронизация молча).
|
||||
CANDIDATES="
|
||||
$app/../ma7engine
|
||||
$app/../../ma7engine
|
||||
$app/../../../ma7engine
|
||||
$app/../../../admins/ma7engine
|
||||
"
|
||||
|
||||
find_engine() {
|
||||
if [ -n "${MA7ENGINE:-}" ]; then
|
||||
if [ ! -d "$MA7ENGINE/core/browser" ]; then
|
||||
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
|
||||
exit 1
|
||||
fi
|
||||
abspath "$MA7ENGINE"
|
||||
return
|
||||
fi
|
||||
for candidate in $CANDIDATES; do
|
||||
if [ -d "$candidate/core/browser" ]; then
|
||||
abspath "$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
|
||||
server_target() {
|
||||
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
|
||||
elif [ -f "$app/server.ts" ]; then echo "$app/core"
|
||||
fi
|
||||
}
|
||||
|
||||
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
|
||||
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
|
||||
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
|
||||
if [ ! -f "$app/index.html" ]; then
|
||||
echo "update: $app не похож на фронт панели (нет index.html)." >&2
|
||||
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
front_to=$app/src/core
|
||||
server_to=$(server_target)
|
||||
|
||||
engine=$(find_engine)
|
||||
|
||||
if [ -z "$engine" ]; then
|
||||
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
|
||||
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
|
||||
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
|
||||
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
|
||||
exit 0
|
||||
fi
|
||||
echo "update: ma7engine не найден ни в одном из мест:" >&2
|
||||
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
|
||||
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "update: ядро — $engine"
|
||||
|
||||
copy() { # copy <откуда> <куда> <подпись>
|
||||
[ -d "$1" ] || return 0
|
||||
rm -rf "$2"
|
||||
mkdir -p "$(dirname "$2")"
|
||||
cp -R "$1" "$2"
|
||||
echo " $3: $1 → $2"
|
||||
}
|
||||
|
||||
copy "$engine/core/browser" "$front_to" "фронт"
|
||||
if [ -n "$server_to" ]; then
|
||||
copy "$engine/core/server" "$server_to" "сервер"
|
||||
fi
|
||||
|
||||
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
|
||||
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
|
||||
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
|
||||
if [ -f "$engine/scripts/doctor.mjs" ]; then
|
||||
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
|
||||
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
|
||||
fi
|
||||
@@ -1,21 +0,0 @@
|
||||
/* Стили панели поверх дизайн-системы ядра (@core/shell/styles.css). Файл
|
||||
подключается после неё, поэтому здесь и добавляются свои классы, и — если
|
||||
очень нужно — переопределяются токены палитры.
|
||||
|
||||
Шаблон палитру НЕ трогает: он показывает дизайн-систему как есть. Свой акцент
|
||||
задаётся так, и обязательно в обеих темах — тёмная переопределяет те же
|
||||
токены у себя, иначе цвет виден только в светлой:
|
||||
|
||||
:root { --primary: #2f6f4f; }
|
||||
:root[data-theme="dark"] { --primary: #7fc4a0; }
|
||||
|
||||
Но менять стоит всю связку токенов акцента (--primary, --primary-container,
|
||||
--on-primary-container, --state-primary), иначе кнопка станет своего цвета,
|
||||
а тональная кнопка и активный пункт меню останутся прежними. */
|
||||
|
||||
/* Своё правило: имя с префиксом панели, чтобы не столкнуться с контрактом ядра. */
|
||||
.demo-note {
|
||||
margin-top: 12px;
|
||||
color: var(--neutral-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
|
||||
// ../backend; ядро в свою панель приезжает через `npm run update`.
|
||||
//
|
||||
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
|
||||
// меню по правам и переключение вкладок — в <Panel />.
|
||||
//
|
||||
// Парная половина — backend/server.ts: вкладки ниже ходят в его
|
||||
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.
|
||||
import Panel, { type PanelConfig } from '@core/shell';
|
||||
import './App.css'; // локальные стили — после стилей ядра
|
||||
import * as tabs from '@/tabs';
|
||||
|
||||
const config: PanelConfig = {
|
||||
tabs,
|
||||
branding: { name: 'Demo Panel', version: '0.1.0' },
|
||||
|
||||
// Ключи localStorage разводятся по панелям: две панели на одном домене
|
||||
// иначе затирают друг другу токен и тему.
|
||||
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
|
||||
|
||||
// loadSession не задан — ядро берёт GET /api/session. Панель без ролей
|
||||
// задаёт его сама и отдаёт permissions: ['*'], тогда эндпоинт сессии
|
||||
// реализовывать не нужно:
|
||||
//
|
||||
// loadSession: async () => ({ user: null, userId: null, role: '', permissions: ['*'] }),
|
||||
|
||||
// slots — свой хром вокруг вкладки: aboveTab (над вкладкой) и headerExtra
|
||||
// (в углу шапки). Слот получает активную вкладку в ctx.tab, поэтому сам
|
||||
// решает, где ему не рисоваться:
|
||||
//
|
||||
// slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return <Panel config={config} />;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Иконки панели. Общие приезжают из ядра и реэкспортятся отсюда, чтобы вкладки
|
||||
// брали все иконки из одного места; здесь остаётся только своё.
|
||||
import { coreIcons } from '@core/icons';
|
||||
|
||||
export * from '@core/icons';
|
||||
|
||||
export const IcoStar = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
|
||||
<path d="M12 3l2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 9.4l6.1-.9z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoGrid = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoBook = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
|
||||
<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v18H6.5A2.5 2.5 0 0 0 4 22z"/><path d="M4 19.5h16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Карта иконок вкладок: имя из metadata.json → компонент. Своё перекрывает
|
||||
// одноимённое из ядра.
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
...coreIcons, // key, globe
|
||||
star: IcoStar,
|
||||
grid: IcoGrid,
|
||||
book: IcoBook,
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
/* Стили только этой вкладки. Всё, что можно было взять из дизайн-системы ядра
|
||||
(.table-card, .chip, .empty), взято оттуда; здесь — типографика документации
|
||||
и блоки кода. */
|
||||
|
||||
.doc-section { margin-bottom: 32px; max-width: 900px; }
|
||||
|
||||
.doc-section > h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.doc-section > p,
|
||||
.doc-section > ul {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--neutral-text);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.doc-section > ul { padding-left: 18px; }
|
||||
.doc-section > ul > li { margin-bottom: 4px; }
|
||||
|
||||
/* Блок кода: моноширинный, со скроллом по горизонтали — длинная строка не
|
||||
растягивает страницу. */
|
||||
.doc-code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: var(--r-card);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.doc-inline {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Предупреждение — то, что ломается молча, если сделать иначе. */
|
||||
.doc-warn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--on-surface);
|
||||
background: var(--error-bg);
|
||||
border-radius: var(--r-card);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
// Инструкция к коду панели — прямо в панели. Не пересказ README ядра целиком,
|
||||
// а то, что нужно под рукой, когда пишешь вкладку: как запустить, куда класть
|
||||
// файлы, что откуда импортировать, где границы ядра и на чём тут спотыкаются.
|
||||
//
|
||||
// Ничего не грузит: открывается без бэкенда.
|
||||
import type { SessionPageProps } from '@core/auth';
|
||||
import './docs.css';
|
||||
|
||||
const RUN = `cd ma7engine # именно из него, не из каталога уровнем выше
|
||||
npm run demo
|
||||
|
||||
# ставит зависимости при первом запуске, поднимает бэкенд :8080 и Vite :5173,
|
||||
# по Ctrl+C гасит обе половины. Открывать :5173, логин admin / demo.`;
|
||||
|
||||
const RUN_SPLIT = `# терминал 1
|
||||
cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start
|
||||
|
||||
# терминал 2
|
||||
cd ma7engine/frontend && npm i && npm run dev`;
|
||||
|
||||
const RUN_PROD = `cd frontend && npm run build # → frontend/dist
|
||||
cd backend && npm start # :8080 отдаёт и API, и собранный фронт`;
|
||||
|
||||
const TREE = `frontend/
|
||||
src/App.tsx конфиг панели: брендинг, ключи стораджа, слоты
|
||||
src/App.css своё поверх дизайн-системы ядра
|
||||
src/components/icons.tsx свои иконки + реэкспорт ядерных, карта ICONS
|
||||
src/tabs/index.ts import.meta.glob → buildTabs (трогать не нужно)
|
||||
src/tabs/<name>/ вкладка: index.tsx + metadata.json + <name>.css
|
||||
src/core/ ЯДРО — копия, правки затираются (npm run update)
|
||||
backend/
|
||||
server.ts конфиг сервера + свои маршруты
|
||||
core/ ЯДРО — копия, правки затираются`;
|
||||
|
||||
const NEW_TAB = `src/tabs/reports/
|
||||
metadata.json { "id": "reports", "label": "Отчёты", "icon": "grid", "order": 40 }
|
||||
index.tsx export default function ReportsPage({ token, showMsg }: SessionPageProps) { … }
|
||||
reports.css стили только этой вкладки (импортит сам index.tsx)`;
|
||||
|
||||
const TAB_CODE = `import { useCallback, useEffect, useState } from 'react';
|
||||
import type { SessionPageProps } from '@core/auth';
|
||||
import { apiFetch, errText } from '@core/http';
|
||||
import { IcoRefresh } from '@/components/icons';
|
||||
import './reports.css';
|
||||
|
||||
export default function ReportsPage({ token, showMsg }: SessionPageProps) {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setRows(await apiFetch('GET', '/api/reports', token));
|
||||
} catch (e) {
|
||||
showMsg(errText(e)); // 401 ловить не надо — ядро разлогинит само
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return rows.length === 0 ? <p className="empty">Пусто</p> : ( … );
|
||||
}`;
|
||||
|
||||
const SESSION_FLOW = `1. Логин POST /login { user, pass } → { token }
|
||||
2. Токен localStorage[storageKeys.token]
|
||||
3. Кто вошёл loadSession(token) → { user, userId, role, permissions }
|
||||
по умолчанию GET /api/session
|
||||
4. Меню вкладки фильтруются по permissions
|
||||
5. Запросы вкладок apiFetch(...) с Authorization: Bearer <token>
|
||||
6. Любой 401 ядро чистит сторадж и показывает логин — во всей панели сразу
|
||||
7. Логаут POST /logout, сервер отзывает токен`;
|
||||
|
||||
const SLOTS_CODE = `slots: {
|
||||
// над контентом вкладки
|
||||
aboveTab: ctx => <ServerBar token={ctx.token} showMsg={ctx.showMsg} />,
|
||||
// в углу шапки, рядом с переключателем темы
|
||||
headerExtra: ctx => <button className="btn btn--tonal">Действие</button>,
|
||||
}
|
||||
|
||||
// ctx: { token, showMsg, session, tab }
|
||||
// Слот гасит себя сам: aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar />`;
|
||||
|
||||
const ROUTE_CODE = `mountRoutes(app, { auth, forwardError }) {
|
||||
// Открыт любому залогиненному — у вкладки нет permission.
|
||||
app.get('/api/reports', auth.requireAuth, async (req, res) => {
|
||||
try { res.json(await db.reports()); }
|
||||
catch (e) { forwardError(res, e); }
|
||||
});
|
||||
|
||||
// Закрыт правом. Гейт на фронте — только UX, граница здесь.
|
||||
app.post('/api/reports', auth.requireAuth,
|
||||
auth.requirePermission('reports.manage'), handler);
|
||||
}`;
|
||||
|
||||
const HASH_CODE = `# сгенерировать scrypt-хэш — из backend/
|
||||
npx tsx -e "import { hashPassword } from './core/password'; console.log(hashPassword('s3cret'))"
|
||||
# → scrypt:a62e2888…:82651449…
|
||||
|
||||
# запуск без открытого пароля в окружении
|
||||
ADMIN_USER=root ADMIN_PASS_HASH='scrypt:…' ADMIN_JWT_SECRET=… npm start`;
|
||||
|
||||
const CONFIG_CODE = `const config: PanelConfig = {
|
||||
tabs, // результат buildTabs()
|
||||
branding: { name: 'Demo Panel', version: '0.1.0' },
|
||||
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
|
||||
endpoints: { login: '/login', logout: '/logout' },
|
||||
loadSession: async token => ({ … }), // по умолчанию GET /api/session
|
||||
slots: { aboveTab, headerExtra },
|
||||
};`;
|
||||
|
||||
const NO_ROLES_CODE = `// Панель без ролей: эндпоинт сессии реализовывать не нужно —
|
||||
// отдаём владельца прямо из конфига, и все вкладки видны.
|
||||
loadSession: async () => ({
|
||||
user: null, userId: null, role: '', permissions: ['*'],
|
||||
}),`;
|
||||
|
||||
const META = [
|
||||
['id', 'да', 'ключ активной вкладки, должен быть уникален'],
|
||||
['label', 'да', 'подпись в меню и заголовок раздела'],
|
||||
['icon', 'да', 'имя из карты ICONS в src/components/icons.tsx'],
|
||||
['category', 'нет', 'группа в меню; без неё — плоский список'],
|
||||
['permission', 'нет', 'право доступа; без него вкладка видна всем залогиненным'],
|
||||
['order', 'нет', 'порядок по возрастанию, по умолчанию 0'],
|
||||
['devOnly', 'нет', 'вырезается из прод-сборки (import.meta.env.PROD)'],
|
||||
];
|
||||
|
||||
const IMPORTS = [
|
||||
['@core/auth', 'SessionPageProps, BasePageProps, hasPermission, OWNER_PERMISSION'],
|
||||
['@core/http', 'apiFetch, errText'],
|
||||
['@core/utils', 'fmtDateTime, timeAgo, bytes, downloadFile, copyText'],
|
||||
['@core/icons', 'coreIcons и Ico* — но вкладки берут их через @/components/icons'],
|
||||
['@core/shell', 'Panel, PanelConfig, SlotContext — только для App.tsx'],
|
||||
['@core/tabs', 'buildTabs — только для src/tabs/index.ts'],
|
||||
['@/…', 'свой код панели: @/components/icons, @/lib/…'],
|
||||
];
|
||||
|
||||
const PROPS = [
|
||||
['token', 'строка для apiFetch'],
|
||||
['showMsg(text)', 'снэкбар на 3 секунды'],
|
||||
['username, userId, role', 'кто вошёл'],
|
||||
['permissions', 'права текущей учётки; hasPermission() из @core/auth'],
|
||||
['availablePermissions', 'все permission всех вкладок — нужно вкладке выдачи прав'],
|
||||
['onNavigate(tabId)', 'переключить активную вкладку (роутера в панели нет)'],
|
||||
];
|
||||
|
||||
const ENV = [
|
||||
['ADMIN_USER', 'нет', 'логин; по умолчанию admin'],
|
||||
['ADMIN_PASS', 'да', 'пароль открытым текстом — либо ADMIN_PASS_HASH'],
|
||||
['ADMIN_PASS_HASH', 'нет', 'scrypt-хэш: тогда открытого пароля в окружении нет'],
|
||||
['ADMIN_JWT_SECRET','да', 'секрет подписи сессий; без него процесс не стартует'],
|
||||
['PORT', 'нет', 'порт бэкенда, по умолчанию 8080'],
|
||||
['STATIC_DIR', 'нет', 'где лежит собранный фронт, по умолчанию ../frontend/dist'],
|
||||
];
|
||||
|
||||
const TOKENS = [
|
||||
['акцент', '--primary --on-primary --primary-container --on-primary-container'],
|
||||
['фон', '--surface --surface-bright --surface-container --surface-hi --on-surface'],
|
||||
['границы', '--outline --outline-variant'],
|
||||
['статусы', '--success-bg/-text/-dot --neutral-bg/-text/-dot --error-bg/-text/-dot'],
|
||||
['опасное', '--danger-text --danger-border'],
|
||||
['прочее', '--label --state-primary --state-row --state-neutral --scrim --shadow'],
|
||||
['типографика', '--font --mono'],
|
||||
['радиусы', '--r-pill --r-card --r-table --r-logo'],
|
||||
];
|
||||
|
||||
const MISTAKES: [string, string][] = [
|
||||
['Правка в src/core или backend/core',
|
||||
'затрётся при следующем npm run update. Нужно поведение иначе — новое поле в PanelConfig ядра либо своё в панели.'],
|
||||
['Иконка не зарегистрирована в ICONS',
|
||||
'сборка TABS падает с внятной ошибкой при старте — это не баг, а валидация манифеста.'],
|
||||
['Импорт через ../../lib/…',
|
||||
'папку вкладки нельзя перенести. Только @core/* и @/*.'],
|
||||
['Свой цвет задан одним :root',
|
||||
'в тёмной теме его не видно: она переопределяет те же токены. Задавай в обеих.'],
|
||||
['permission у вкладки без проверки на маршруте',
|
||||
'доступ не закрыт: гейт на фронте только прячет пункт меню.'],
|
||||
['Панель не стартует без ADMIN_PASS',
|
||||
'так задумано (fail-closed): панель с паролем по умолчанию хуже не поднявшейся.'],
|
||||
];
|
||||
|
||||
export default function DocsPage(_props: SessionPageProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="doc-section">
|
||||
<h3>Запуск демо</h3>
|
||||
<p>
|
||||
Шаблон — рабочая панель на ядре. Две половины запускаются отдельно: бэкенд отдаёт
|
||||
API, Vite — фронт и проксирует на бэкенд <span className="doc-inline">/login</span>,{' '}
|
||||
<span className="doc-inline">/logout</span> и <span className="doc-inline">/api</span>.
|
||||
</p>
|
||||
<div className="doc-code">{RUN}</div>
|
||||
<p>
|
||||
Если половины нужны врозь — два терминала, полными путями:{' '}
|
||||
<span className="doc-inline">npm start</span> терминал не возвращает.
|
||||
</p>
|
||||
<div className="doc-code">{RUN_SPLIT}</div>
|
||||
<p>Прод-режим — одним процессом, без Vite:</p>
|
||||
<div className="doc-code">{RUN_PROD}</div>
|
||||
<p>
|
||||
Занят порт — <span className="doc-inline">PORT=8081 npm start</span> и{' '}
|
||||
<span className="doc-inline">VITE_PROXY_TARGET=http://localhost:8081 npm run dev</span>.
|
||||
Ядро приезжает копией: <span className="doc-inline">npm run update</span> кладёт его в{' '}
|
||||
<span className="doc-inline">src/core</span> и{' '}
|
||||
<span className="doc-inline">backend/core</span>. Руками звать не нужно — он
|
||||
вызывается сам перед <span className="doc-inline">dev</span> и{' '}
|
||||
<span className="doc-inline">build</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Что где лежит</h3>
|
||||
<p>
|
||||
Панель — это конфиг плюс вкладки. Оболочка (логин, тема, меню, сессия),
|
||||
дизайн-система и сборка реестра вкладок живут в ядре и приезжают копией.
|
||||
</p>
|
||||
<div className="doc-code">{TREE}</div>
|
||||
<div className="doc-warn">
|
||||
<span>⚠</span>
|
||||
<span>
|
||||
В <span className="doc-inline">src/core</span> и{' '}
|
||||
<span className="doc-inline">backend/core</span> писать нельзя: это копия,
|
||||
её затирает <span className="doc-inline">npm run update</span>. Нужно поведение
|
||||
иначе — либо новое поле в <span className="doc-inline">PanelConfig</span> ядра,
|
||||
либо своё в панели (слот, свой класс, свой маршрут).
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Как добавить вкладку</h3>
|
||||
<p>
|
||||
Создай папку с тремя файлами — реестр найдёт её сам через{' '}
|
||||
<span className="doc-inline">import.meta.glob</span>, править{' '}
|
||||
<span className="doc-inline">src/tabs/index.ts</span> не нужно.
|
||||
</p>
|
||||
<div className="doc-code">{NEW_TAB}</div>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>поле metadata.json</th><th>обяз.</th><th>смысл</th></tr></thead>
|
||||
<tbody>
|
||||
{META.map(([field, req, meaning]) => (
|
||||
<tr key={field}>
|
||||
<td className="td-mono">{field}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{req}
|
||||
</span>
|
||||
</td>
|
||||
<td>{meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
Манифест — JSON, компилятор его не проверяет, поэтому он валидируется в рантайме:
|
||||
опечатка или незарегистрированная иконка падают с внятной ошибкой при сборке{' '}
|
||||
<span className="doc-inline">TABS</span>, а не тихо в UI.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Скелет вкладки</h3>
|
||||
<div className="doc-code">{TAB_CODE}</div>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>проп</th><th>что это</th></tr></thead>
|
||||
<tbody>
|
||||
{PROPS.map(([name, meaning]) => (
|
||||
<tr key={name}><td className="td-mono">{name}</td><td>{meaning}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
Панель без ролей объявляет <span className="doc-inline">BasePageProps</span> — там
|
||||
только <span className="doc-inline">token</span> и{' '}
|
||||
<span className="doc-inline">showMsg</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Откуда что импортировать</h3>
|
||||
<p>
|
||||
Только через алиасы: <span className="doc-inline">@core/*</span> — ядро,{' '}
|
||||
<span className="doc-inline">@/*</span> — свой код. Относительные{' '}
|
||||
<span className="doc-inline">../../</span> ломаются при переносе папки вкладки.
|
||||
</p>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>модуль</th><th>что берут</th></tr></thead>
|
||||
<tbody>
|
||||
{IMPORTS.map(([mod, what]) => (
|
||||
<tr key={mod}><td className="td-mono">{mod}</td><td>{what}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Жизненный цикл сессии</h3>
|
||||
<p>Всё это делает оболочка — вкладке остаётся только ходить с токеном в свои маршруты.</p>
|
||||
<div className="doc-code">{SESSION_FLOW}</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Права — два слоя</h3>
|
||||
<p>
|
||||
<b>Фронт:</b> <span className="doc-inline">permission</span> в{' '}
|
||||
<span className="doc-inline">metadata.json</span> прячет вкладку из меню, а{' '}
|
||||
<span className="doc-inline">hasPermission(permissions, 'x')</span> — отдельные кнопки
|
||||
внутри неё. Это UX.
|
||||
</p>
|
||||
<p>
|
||||
<b>Бэкенд:</b> <span className="doc-inline">auth.requirePermission('x')</span> на
|
||||
маршруте. Это граница. Первое без второго — не защита: запрос можно послать руками.
|
||||
</p>
|
||||
<p>
|
||||
<span className="doc-inline">'*'</span> в{' '}
|
||||
<span className="doc-inline">permissions</span> — владелец, проходит любую проверку.
|
||||
Панель без ролей просто отдаёт его всем:
|
||||
</p>
|
||||
<div className="doc-code">{NO_ROLES_CODE}</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Свой хром — слоты</h3>
|
||||
<p>
|
||||
Если нужен общий для вкладок элемент (панель выбора сервера, кнопка действия в шапке),
|
||||
он не копируется по вкладкам, а отдаётся оболочке слотом.
|
||||
</p>
|
||||
<div className="doc-code">{SLOTS_CODE}</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Конфиг панели — App.tsx</h3>
|
||||
<p>Всё, чем панели отличаются друг от друга, живёт здесь и больше нигде.</p>
|
||||
<div className="doc-code">{CONFIG_CODE}</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Маршруты — backend/server.ts</h3>
|
||||
<p>
|
||||
Логин, сессия, права, рейт-лимит (5 попыток с IP за 15 минут) и раздача фронта — в{' '}
|
||||
<span className="doc-inline">createPanelServer</span>; свои маршруты монтируются
|
||||
между логином и статикой. Порядок фиксирован внутри: статика отвечает на любой GET,
|
||||
поэтому маршрут после неё не сработал бы.
|
||||
</p>
|
||||
<div className="doc-code">{ROUTE_CODE}</div>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>переменная</th><th>обяз.</th><th>смысл</th></tr></thead>
|
||||
<tbody>
|
||||
{ENV.map(([name, req, meaning]) => (
|
||||
<tr key={name}>
|
||||
<td className="td-mono">{name}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{req}
|
||||
</span>
|
||||
</td>
|
||||
<td>{meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="doc-warn">
|
||||
<span>⚠</span>
|
||||
<span>
|
||||
Без <span className="doc-inline">ADMIN_PASS</span> и{' '}
|
||||
<span className="doc-inline">ADMIN_JWT_SECRET</span> процесс не поднимется — это
|
||||
не баг: панель, тихо работающая с паролем по умолчанию, хуже не стартовавшей.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Учётка: логин и пароль</h3>
|
||||
<p>
|
||||
Учётка в панели одна и живёт в окружении бэкенда, а не в базе:{' '}
|
||||
<span className="doc-inline">ADMIN_USER</span> (по умолчанию{' '}
|
||||
<span className="doc-inline">admin</span>) и{' '}
|
||||
<span className="doc-inline">ADMIN_PASS</span>. В демо пароль —{' '}
|
||||
<span className="doc-inline">demo</span>, его подставляет скрипт запуска.
|
||||
</p>
|
||||
<p>
|
||||
Пароль сравнивается за постоянное время и проверяется даже при несовпавшем
|
||||
логине — иначе по времени ответа «нет такого пользователя» отличается от
|
||||
«неверный пароль». Чтобы открытого пароля не было в окружении вовсе, вместо{' '}
|
||||
<span className="doc-inline">ADMIN_PASS</span> задаётся{' '}
|
||||
<span className="doc-inline">ADMIN_PASS_HASH</span>:
|
||||
</p>
|
||||
<div className="doc-code">{HASH_CODE}</div>
|
||||
<p>
|
||||
После входа панель кладёт JWT (TTL 12 часов) в{' '}
|
||||
<span className="doc-inline">localStorage</span> под ключом из{' '}
|
||||
<span className="doc-inline">storageKeys.token</span>; логаут отзывает токен на
|
||||
сервере. Учёток нужно несколько — либо размечай их сам и выдавай разные{' '}
|
||||
<span className="doc-inline">claims</span>, либо не заводи учёток в панели
|
||||
вовсе: тогда она собирает сервер сама из кусков ядра, а логин форвардит в
|
||||
основной продукт, где настоящие учётки уже есть.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Стили и токены</h3>
|
||||
<p>
|
||||
Имена классов ядра — публичный контракт, все они с подписями на вкладке «Витрина».
|
||||
Цвета — только через <span className="doc-inline">var(--…)</span>: палитра задана на{' '}
|
||||
<span className="doc-inline">:root</span> и переопределена на{' '}
|
||||
<span className="doc-inline">:root[data-theme="dark"]</span>, поэтому свой цвет
|
||||
задаётся сразу в обеих темах. Несуществующий токен не ошибка — правило просто молча
|
||||
не применится, отсюда список ниже.
|
||||
</p>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>группа</th><th>токены</th></tr></thead>
|
||||
<tbody>
|
||||
{TOKENS.map(([group, list]) => (
|
||||
<tr key={group}><td>{group}</td><td className="td-mono">{list}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
Стили вкладки лежат в её папке и импортятся её же компонентом; общий{' '}
|
||||
<span className="doc-inline">App.css</span> подключается после стилей ядра.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>Обновление ядра</h3>
|
||||
<p>
|
||||
<span className="doc-inline">npm run update</span> во фронте копирует обе половины
|
||||
ядра в панель: <span className="doc-inline">core/browser</span> →{' '}
|
||||
<span className="doc-inline">frontend/src/core</span> и{' '}
|
||||
<span className="doc-inline">core/server</span> →{' '}
|
||||
<span className="doc-inline">backend/core</span>. Он же вызывается сам перед{' '}
|
||||
<span className="doc-inline">npm run dev</span> и{' '}
|
||||
<span className="doc-inline">npm run build</span>. В контейнере ядра рядом нет —
|
||||
скрипт видит уже синхронизированную копию и молча выходит с нулём. Путь к ядру можно
|
||||
задать явно: <span className="doc-inline">MA7ENGINE=/путь/к/ma7engine</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="doc-section">
|
||||
<h3>На чём тут спотыкаются</h3>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>что сделал</th><th>что будет</th></tr></thead>
|
||||
<tbody>
|
||||
{MISTAKES.map(([what, result]) => (
|
||||
<tr key={what}><td>{what}</td><td>{result}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "docs",
|
||||
"label": "Инструкция",
|
||||
"icon": "book",
|
||||
"order": 5
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Вкладка без permission — видна любому залогиненному. Ходит в /api/demo/whoami
|
||||
// из backend/server.ts и показывает клеймы текущей сессии.
|
||||
//
|
||||
// Пропсы приходят от оболочки: SessionPageProps — это token, showMsg и данные
|
||||
// сессии. Панель без ролей объявляет BasePageProps: только token и showMsg.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { SessionPageProps } from '@core/auth';
|
||||
import { apiFetch, errText } from '@core/http';
|
||||
import { IcoRefresh } from '@/components/icons';
|
||||
|
||||
export default function HelloPage({ token, showMsg, username, role }: SessionPageProps) {
|
||||
const [session, setSession] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setSession(await apiFetch('GET', '/api/demo/whoami', token));
|
||||
} catch (e) {
|
||||
// Ловить 401 не нужно: ядро на него разлогинивает панель само.
|
||||
showMsg(errText(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<span className="chip chip--online">
|
||||
<span className="chip-dot" />{username || '—'} · {role || 'без роли'}
|
||||
</span>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--tonal" onClick={load} disabled={loading}>
|
||||
<IcoRefresh /> {loading ? 'Обновляю…' : 'Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>Поле</th><th>Значение</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(session ?? {}).map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="td-mono">{key}</td>
|
||||
<td className="td-mono">{JSON.stringify(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!session && !loading && <p className="empty">Сессия не загружена</p>}
|
||||
</div>
|
||||
|
||||
<p className="demo-note">
|
||||
Классы выше — из дизайн-системы ядра; <code>.demo-note</code> — своё, из App.css.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "hello",
|
||||
"label": "Кто я",
|
||||
"icon": "star",
|
||||
"order": 10
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Реестр вкладок. Сборка (валидация metadata.json, порядок, категории, права,
|
||||
// devOnly) живёт в ядре — в панели остаётся только поиск папок: import.meta.glob
|
||||
// это compile-time фича Vite, её паттерн резолвится относительно файла, где
|
||||
// написан, и уехав в ядро она сканировала бы ядро, а не src/tabs/ панели.
|
||||
//
|
||||
// Добавить вкладку = создать папку с index.tsx и metadata.json. Этот файл
|
||||
// трогать не нужно.
|
||||
import { buildTabs } from '@core/tabs';
|
||||
import { ICONS } from '@/components/icons';
|
||||
|
||||
export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
|
||||
import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }),
|
||||
import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }),
|
||||
ICONS,
|
||||
);
|
||||
@@ -1,151 +0,0 @@
|
||||
// Витрина дизайн-системы ядра: все классы публичного контракта из
|
||||
// @core/shell/styles.css в одном месте, с подписями. Ничего не грузит —
|
||||
// смотреть можно и без бэкенда.
|
||||
//
|
||||
// Заодно это тест на ломающие правки: переименовал класс в ядре — увидел здесь.
|
||||
import { useState } from 'react';
|
||||
import type { SessionPageProps } from '@core/auth';
|
||||
import { IcoCopy, IcoPlus, IcoRefresh, IcoTrash } from '@/components/icons';
|
||||
import './kit.css';
|
||||
|
||||
// Подпись с именем класса под каждым образцом.
|
||||
function Item({ name, children }: { name: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="kit-item">
|
||||
{children}
|
||||
<span className="kit-name">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KitPage({ showMsg }: SessionPageProps) {
|
||||
const [modal, setModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="kit-section">
|
||||
<h3>Кнопки</h3>
|
||||
<div className="kit-row">
|
||||
<Item name=".btn .btn--primary"><button className="btn btn--primary">Сохранить</button></Item>
|
||||
<Item name=".btn .btn--tonal"><button className="btn btn--tonal"><IcoRefresh /> Обновить</button></Item>
|
||||
<Item name=".btn .btn--outline"><button className="btn btn--outline">Отмена</button></Item>
|
||||
<Item name=".btn .btn--danger"><button className="btn btn--danger"><IcoTrash /> Удалить</button></Item>
|
||||
<Item name=":disabled"><button className="btn btn--primary" disabled>Недоступно</button></Item>
|
||||
<Item name=".btn--sq"><button className="btn btn--primary btn--sq" aria-label="Добавить"><IcoPlus /></button></Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Кнопки-иконки — для действий в строке таблицы</h3>
|
||||
<div className="kit-row">
|
||||
<Item name=".btn-icon"><button className="btn-icon" aria-label="Копировать"><IcoCopy /></button></Item>
|
||||
<Item name=".btn-icon--neutral"><button className="btn-icon btn-icon--neutral" aria-label="Обновить"><IcoRefresh /></button></Item>
|
||||
<Item name=".btn-icon--danger"><button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button></Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Поля</h3>
|
||||
<div className="kit-row">
|
||||
<Item name=".field"><input className="field" placeholder="Обычное поле" /></Item>
|
||||
<Item name=".field type=password"><input className="field" type="password" placeholder="Пароль" /></Item>
|
||||
<Item name=".field :disabled"><input className="field" placeholder="Недоступно" disabled /></Item>
|
||||
</div>
|
||||
<div className="kit-row" style={{ marginTop: 12 }}>
|
||||
<Item name=".btn--full"><button className="btn btn--primary btn--full">Во всю ширину</button></Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Тулбар — поле растягивается, кнопки прижаты вправо</h3>
|
||||
<div className="toolbar">
|
||||
<input className="field" placeholder="Поиск" />
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--tonal"><IcoRefresh /> Обновить</button>
|
||||
<button className="btn btn--primary"><IcoPlus /> Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="kit-name">.toolbar · .toolbar-btns</span>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Чипы — статус одним взглядом</h3>
|
||||
<div className="kit-row">
|
||||
<Item name=".chip--online"><span className="chip chip--online"><span className="chip-dot" />онлайн</span></Item>
|
||||
<Item name=".chip--offline"><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></Item>
|
||||
<Item name=".chip--error"><span className="chip chip--error"><span className="chip-dot" />ошибка</span></Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Таблица</h3>
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Имя</th><th>Ключ</th><th>Статус</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="td-num">1</td>
|
||||
<td>Первая запись</td>
|
||||
<td className="td-mono">a1b2c3d4</td>
|
||||
<td><span className="chip chip--online"><span className="chip-dot" />онлайн</span></td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
|
||||
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="td-num">2</td>
|
||||
<td>Вторая запись</td>
|
||||
<td className="td-mono">e5f6a7b8</td>
|
||||
<td><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
|
||||
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span className="kit-name">.table-card · .td-num · .td-mono · .td-actions · .actions</span>
|
||||
</section>
|
||||
|
||||
<section className="kit-section">
|
||||
<h3>Пустое состояние и снэкбар</h3>
|
||||
<div className="table-card">
|
||||
<p className="empty">Ничего не найдено</p>
|
||||
</div>
|
||||
<div className="kit-row" style={{ marginTop: 10 }}>
|
||||
<Item name="showMsg() → .snack">
|
||||
<button className="btn btn--outline" onClick={() => showMsg('Сообщение живёт 3 секунды')}>
|
||||
Показать снэкбар
|
||||
</button>
|
||||
</Item>
|
||||
<Item name=".modal-*">
|
||||
<button className="btn btn--outline" onClick={() => setModal(true)}>Открыть модалку</button>
|
||||
</Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{modal && (
|
||||
<div className="modal-backdrop" onClick={() => setModal(false)}>
|
||||
<div className="modal-card" onClick={e => e.stopPropagation()}>
|
||||
<span className="modal-title">Удалить запись?</span>
|
||||
<p className="modal-hint">Действие необратимо.</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn--outline" onClick={() => setModal(false)}>Отмена</button>
|
||||
<button className="btn btn--danger" onClick={() => { setModal(false); showMsg('Удалено'); }}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/* Стили только этой вкладки — живут в её папке и импортятся её же компонентом.
|
||||
Всё, что показано ниже, нарисовано классами ядра; здесь только раскладка
|
||||
витрины и подписи с именами классов. */
|
||||
|
||||
.kit-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.kit-section > h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--neutral-text);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.kit-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Подпись с именем класса — чтобы витрина была ещё и справочником. */
|
||||
.kit-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--neutral-dot);
|
||||
}
|
||||
.kit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "kit",
|
||||
"label": "Витрина",
|
||||
"icon": "grid",
|
||||
"order": 30
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Вкладка с permission: без права secrets.view её нет в меню. Гейт на фронте —
|
||||
// это только UX; настоящая граница на маршруте бэкенда (requirePermission в
|
||||
// backend/server.ts).
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { SessionPageProps } from '@core/auth';
|
||||
import { apiFetch, errText } from '@core/http';
|
||||
|
||||
export default function SecretsPage({ token, showMsg }: SessionPageProps) {
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/api/demo/secrets', token);
|
||||
setItems(data.items ?? []);
|
||||
} catch (e) {
|
||||
showMsg(errText(e));
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (items.length === 0) return <p className="empty">Пусто</p>;
|
||||
|
||||
return (
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Секрет</th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={item}><td className="td-num">{i + 1}</td><td>{item}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"id": "secrets",
|
||||
"label": "Секреты",
|
||||
"icon": "key",
|
||||
"permission": "secrets.view",
|
||||
"order": 20
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "node"], // node — для vite.config.ts (__dirname, node:path)
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["./src/core/*"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
// В деве Vite (:5173) проксирует запросы на бэкенд (:8080) — фронт всегда ходит
|
||||
// по относительным путям, порт нигде не зашит.
|
||||
const target = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Ядро — вендоренная копия: `npm run update` (он же predev/prebuild)
|
||||
// кладёт сюда ma7engine/core/browser. Копия, а не путь в соседнюю
|
||||
// папку, потому что в образе панели пути выше её каталога нет.
|
||||
'@core': path.resolve(__dirname, 'src/core'),
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': target,
|
||||
'/login': target,
|
||||
'/logout': target,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "ma7engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Общее ядро админ-панелей: оболочка, реестр вкладок, дизайн-система",
|
||||
"scripts": {
|
||||
"demo": "sh scripts/demo.sh",
|
||||
"new-panel": "node scripts/new-panel.mjs",
|
||||
"release": "sh scripts/release.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"express": "^4.19.0",
|
||||
"jsonwebtoken": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"react": "^18.3.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"jsonwebtoken": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"axios": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.
|
||||
# Ctrl+C гасит обе половины.
|
||||
#
|
||||
# npm run demo (из корня ma7engine)
|
||||
# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)
|
||||
#
|
||||
# Учётка по умолчанию — admin / demo. Переопределяется окружением:
|
||||
# ADMIN_USER=root ADMIN_PASS=secret npm run demo
|
||||
set -eu
|
||||
|
||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root=$(dirname "$here")
|
||||
|
||||
# Пароль и секрет обязательны для ядра (fail-closed), поэтому у демо есть
|
||||
# заведомо непригодные для прода значения по умолчанию.
|
||||
ADMIN_PASS=${ADMIN_PASS:-demo}
|
||||
ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-dev-secret}
|
||||
export ADMIN_PASS ADMIN_JWT_SECRET
|
||||
|
||||
for half in backend frontend; do
|
||||
if [ ! -d "$root/$half/node_modules" ]; then
|
||||
echo "demo: ставлю зависимости в $half…"
|
||||
(cd "$root/$half" && npm i)
|
||||
fi
|
||||
done
|
||||
|
||||
(cd "$root/backend" && npm start) &
|
||||
backend=$!
|
||||
|
||||
# Гасим бэкенд вместе с собой — иначе он останется висеть на :8080. Идём по
|
||||
# всему дереву: между нами и слушающим порт node стоят подоболочка, npm и sh,
|
||||
# и сигнал в верхний процесс до нижнего сам не доходит — тот просто осиротеет.
|
||||
kill_tree() {
|
||||
for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child"; done
|
||||
kill "$1" 2>/dev/null || true
|
||||
}
|
||||
cleanup() { kill_tree "$backend"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "demo: бэкенд на :8080, фронт поднимается на :5173 — открывать его"
|
||||
echo "demo: логин admin / ${ADMIN_PASS}"
|
||||
|
||||
cd "$root/frontend" && npm run dev
|
||||
@@ -1,379 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Проверяет, что панель собрана и осталась по стандарту ma7engine. Отвечает на
|
||||
// вопрос, на который раньше ответа не было: «эта панель ещё соответствует ядру,
|
||||
// или уже уехала?» — именно расхождение трёх форков и породило само ядро.
|
||||
//
|
||||
// npm run doctor (из <панель>/frontend)
|
||||
// node scripts/doctor.mjs ../admin-hub (из ma7engine, по чужому пути)
|
||||
//
|
||||
// Ошибки (выход 1) — то, из-за чего панель соберётся неправильно или не
|
||||
// соберётся вовсе. Предупреждения (выход 0) — следы шаблона, оставшиеся после
|
||||
// копирования: они не ломают сборку, поэтому не должны ломать и CI.
|
||||
//
|
||||
// Скрипт распространяется в панели вместе с ядром — его кладёт update.sh рядом
|
||||
// с собой, так что проверки в панели всегда те же, что в ядре.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ─── где панель ─────────────────────────────────────────────────────────────
|
||||
// Аргументом — путь к панели; без аргумента корнем считается ближайший предок,
|
||||
// в котором лежит frontend/package.json. Скрипт живёт либо в
|
||||
// <панель>/frontend/scripts (куда его кладёт update.sh), либо в
|
||||
// ma7engine/scripts — оба случая этот подъём разбирает одинаково, и он не
|
||||
// зависит от того, какие ещё каталоги оказались рядом.
|
||||
function resolvePanel() {
|
||||
const arg = process.argv[2];
|
||||
if (arg) return path.resolve(arg);
|
||||
for (let dir = HERE; ; dir = path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'frontend/package.json'))) return dir;
|
||||
if (dir === path.dirname(dir)) break;
|
||||
}
|
||||
return path.dirname(HERE);
|
||||
}
|
||||
|
||||
const PANEL = resolvePanel();
|
||||
const FRONT = path.join(PANEL, 'frontend');
|
||||
|
||||
// Панель из одной папки (сервер рядом с фронтом, как было в awg-ui) держит
|
||||
// серверную половину ядра в <панель>/core, а не в <панель>/backend/core.
|
||||
const BACK = fs.existsSync(path.join(PANEL, 'backend')) ? path.join(PANEL, 'backend') : PANEL;
|
||||
const SERVER_CORE = path.join(BACK, 'core');
|
||||
|
||||
// Сам шаблон ma7engine — панель по всем структурным признакам, но demo_token и
|
||||
// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом.
|
||||
const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser'));
|
||||
|
||||
// Ядро попадает в панель одним из двух способов, и проверки у них разные:
|
||||
//
|
||||
// subtree — панель лежит в git-репозитории продукта, ядро подтянуто
|
||||
// `git subtree add` и потому отслеживается git. Обновляется
|
||||
// `git subtree pull`; update.sh здесь не нужен и вреден — он
|
||||
// сносит каталог целиком и затёр бы поддерево.
|
||||
//
|
||||
// copy — ядро раскладывает update.sh из соседнего ma7engine. Так устроен
|
||||
// сам шаблон, и так живёт панель вне git.
|
||||
function coreIsTracked(dir) {
|
||||
try {
|
||||
const out = execFileSync('git', ['ls-files', '--', dir],
|
||||
{ cwd: PANEL, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
return out.toString().trim() !== '';
|
||||
} catch { return false; }
|
||||
}
|
||||
const MODE = !IS_TEMPLATE && coreIsTracked(path.join(FRONT, 'src/core')) ? 'subtree' : 'copy';
|
||||
|
||||
// ─── накопитель результатов ─────────────────────────────────────────────────
|
||||
const errors = [], warns = [], skips = [], oks = [];
|
||||
|
||||
const ok = (m) => oks.push(m);
|
||||
const err = (m, w) => errors.push(w ? `${m}\n ${w}` : m);
|
||||
const warn = (m, w) => warns.push(w ? `${m}\n ${w}` : m);
|
||||
const skip = (m) => skips.push(m);
|
||||
|
||||
const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
|
||||
|
||||
// Проверка «файл есть и в нём встречается X»; возвращает текст либо null.
|
||||
function need(file, label) {
|
||||
const text = read(file);
|
||||
if (text === null) err(`${label}: нет файла ${path.relative(PANEL, file)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
// ─── 1. раскладка и сборка ──────────────────────────────────────────────────
|
||||
if (!fs.existsSync(FRONT)) {
|
||||
console.error(`doctor: в ${PANEL} нет каталога frontend — это не панель ma7engine`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updateSh = path.join(FRONT, 'scripts/update.sh');
|
||||
if (MODE === 'subtree') {
|
||||
if (fs.existsSync(updateSh)) {
|
||||
err('в subtree-панели остался frontend/scripts/update.sh',
|
||||
'он сносит каталог ядра целиком (rm -rf) и затрёт подтянутое поддерево — ' +
|
||||
'обновление здесь делается git subtree pull');
|
||||
} else ok('ядро приходит через git subtree (update.sh не нужен)');
|
||||
} else if (fs.existsSync(updateSh)) ok('update.sh на месте');
|
||||
else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется');
|
||||
|
||||
const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт');
|
||||
const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null;
|
||||
if (frontPkg) {
|
||||
const s = frontPkg.scripts ?? {};
|
||||
if (MODE === 'subtree') {
|
||||
const stale = ['update', 'predev', 'prebuild'].filter(h => /update/.test(s[h] ?? ''));
|
||||
if (stale.length) err(`frontend/package.json: остались хуки копирования (${stale.join(', ')})`,
|
||||
'ядро приходит поддеревом — эти скрипты его затрут');
|
||||
else ok('в package.json нет хуков копирования ядра');
|
||||
} else {
|
||||
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh');
|
||||
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh');
|
||||
|
||||
for (const hook of ['predev', 'prebuild']) {
|
||||
if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`);
|
||||
else err(`frontend/package.json: нет "${hook}": "npm run update"`,
|
||||
'без него dev и build идут на устаревшей копии ядра');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tsconfig.json шаблона содержит //-комментарий — JSON.parse на нём падает,
|
||||
// поэтому конфиги проверяются регулярками, а не разбором.
|
||||
const tsconf = need(path.join(FRONT, 'tsconfig.json'), 'фронт');
|
||||
if (tsconf) {
|
||||
const pairs = [[/"@core\/\*"\s*:\s*\[\s*"\.\/src\/core\/\*"\s*\]/, '@core/* → ./src/core/*'],
|
||||
[/"@\/\*"\s*:\s*\[\s*"\.\/src\/\*"\s*\]/, '@/* → ./src/*']];
|
||||
for (const [re, label] of pairs) {
|
||||
if (re.test(tsconf)) ok(`tsconfig: ${label}`);
|
||||
else err(`tsconfig.json: нет пути ${label}`, 'вкладки перестанут резолвить импорты ядра');
|
||||
}
|
||||
}
|
||||
|
||||
const viteConf = need(path.join(FRONT, 'vite.config.ts'), 'фронт');
|
||||
if (viteConf) {
|
||||
for (const [re, label] of [[/'@core'\s*:/, '@core'], [/'@'\s*:/, '@']]) {
|
||||
if (re.test(viteConf)) ok(`vite: алиас ${label}`);
|
||||
else err(`vite.config.ts: нет алиаса ${label}`, 'сборка не найдёт то, что находит tsc');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. вендоренная копия ядра ──────────────────────────────────────────────
|
||||
const BROWSER_CORE = path.join(FRONT, 'src/core');
|
||||
|
||||
if (!fs.existsSync(BROWSER_CORE)) {
|
||||
err('нет frontend/src/core', 'ядро ни разу не приезжало — запусти npm run update');
|
||||
}
|
||||
|
||||
// Хэш дерева: имена файлов и содержимое, в устойчивом порядке.
|
||||
function treeHash(dir) {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const h = crypto.createHash('sha256');
|
||||
const walk = (d, prefix) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
||||
if (e.name === '.DS_Store') continue; // Finder кладёт его в любую папку
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p, `${prefix}${e.name}/`);
|
||||
else { h.update(`${prefix}${e.name}\0`); h.update(fs.readFileSync(p)); }
|
||||
}
|
||||
};
|
||||
walk(dir, '');
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
// Ядро ищется тем же перебором, что в update.sh — порядок от ближнего к дальнему.
|
||||
function findEngine() {
|
||||
if (process.env.MA7ENGINE) return process.env.MA7ENGINE;
|
||||
if (IS_TEMPLATE) return PANEL;
|
||||
for (const c of [path.join(FRONT, '../ma7engine'),
|
||||
path.join(FRONT, '../../ma7engine'),
|
||||
path.join(FRONT, '../../../ma7engine'),
|
||||
path.join(FRONT, '../../../admins/ma7engine')]) {
|
||||
if (fs.existsSync(path.join(c, 'core/browser'))) return path.resolve(c);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = findEngine();
|
||||
if (!engine) {
|
||||
skip('сверка копии ядра — ma7engine рядом не найден (укажи MA7ENGINE=/путь)');
|
||||
} else {
|
||||
for (const [copy, src, label] of [[BROWSER_CORE, path.join(engine, 'core/browser'), 'браузерная'],
|
||||
[SERVER_CORE, path.join(engine, 'core/server'), 'серверная']]) {
|
||||
if (!fs.existsSync(copy)) { skip(`сверка: ${label} половина ядра в панель не приезжала`); continue; }
|
||||
if (treeHash(copy) === treeHash(src)) ok(`копия ядра совпадает с ядром (${label})`);
|
||||
else err(`копия ядра разошлась с ma7engine (${label}: ${path.relative(PANEL, copy)})`,
|
||||
'ядро правили руками либо копия устарела — npm run update перезальёт её');
|
||||
}
|
||||
}
|
||||
|
||||
// Ядро обязано ходить относительными путями: с @core внутри себя вендоренная
|
||||
// копия потянула бы за собой требование к настройке потребителя.
|
||||
for (const [dir, label] of [[BROWSER_CORE, 'browser'], [SERVER_CORE, 'server']]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const bad = [];
|
||||
const walk = (d) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (/\.tsx?$/.test(e.name) && /from\s+'@(core)?\//.test(read(p) ?? '')) bad.push(p);
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
if (bad.length) err(`внутри копии ядра есть алиасные импорты (${label}): ` +
|
||||
bad.map(p => path.relative(PANEL, p)).join(', '),
|
||||
'ядро должно импортировать себя относительными путями');
|
||||
else ok(`копия ядра ходит относительными путями (${label})`);
|
||||
}
|
||||
|
||||
// ─── 3. игнор-файлы ─────────────────────────────────────────────────────────
|
||||
// Копия ядра обязана попадать и в репозиторий панели, и в образ: контекст
|
||||
// сборки — каталог панели, ma7engine рядом при сборке нет.
|
||||
//
|
||||
// В самом ma7engine наоборот: там копия — артефакт `npm run update`, источник
|
||||
// лежит в соседнем core/, и коммитить её значило бы держать ядро в одном
|
||||
// репозитории трижды.
|
||||
const gitignore = read(path.join(PANEL, '.gitignore')) ?? read(path.join(FRONT, '.gitignore'));
|
||||
if (gitignore === null) {
|
||||
skip('.gitignore — файла нет, проверять нечего');
|
||||
} else {
|
||||
const ignoresCore = /(^|\/)(frontend\/)?src\/core\s*$/m.test(gitignore)
|
||||
|| /(^|\/)backend\/core\s*$/m.test(gitignore)
|
||||
|| /^core\/?\s*$/m.test(gitignore);
|
||||
if (IS_TEMPLATE) {
|
||||
if (ignoresCore) ok('.gitignore закрывает копию ядра (шаблон — источник рядом в core/)');
|
||||
else warn('шаблон: .gitignore не закрывает копию ядра',
|
||||
'в ma7engine она артефакт npm run update, а источник — соседний core/');
|
||||
} else if (ignoresCore) {
|
||||
err('.gitignore исключает копию ядра из репозитория панели',
|
||||
'образ собирается из чистого checkout\'а, где ma7engine рядом нет — ' +
|
||||
'без закоммиченной копии сборка встанет на импорте @core');
|
||||
} else {
|
||||
ok('копия ядра коммитится вместе с панелью');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3b. образ ──────────────────────────────────────────────────────────────
|
||||
const dockerfile = read(path.join(PANEL, 'Dockerfile'));
|
||||
if (dockerfile === null) {
|
||||
warn('нет Dockerfile', 'панель — один процесс и один образ; шаблон везёт готовый');
|
||||
} else {
|
||||
if (/COPY --from=frontend .*dist/.test(dockerfile)) ok('Dockerfile кладёт собранный фронт в образ');
|
||||
else warn('Dockerfile не копирует frontend/dist из стадии сборки',
|
||||
'сервер отдаёт фронт сам — без dist панель поднимется пустой');
|
||||
}
|
||||
|
||||
const dockerignore = read(path.join(PANEL, '.dockerignore'));
|
||||
if (dockerignore === null) {
|
||||
skip('.dockerignore — файла нет, проверять нечего');
|
||||
} else if (/(^|\/|\*)core\/?\s*$/m.test(dockerignore)) {
|
||||
err('.dockerignore исключает core', 'в образ копия ядра должна приехать с исходниками — ' +
|
||||
'иначе сборка встанет на импорте @core');
|
||||
} else {
|
||||
ok('.dockerignore не исключает копию ядра');
|
||||
}
|
||||
|
||||
// ─── 4. вкладки ─────────────────────────────────────────────────────────────
|
||||
// Те же инварианты, что buildTabs проверяет в рантайме, но до сборки.
|
||||
const tabsDir = path.join(FRONT, 'src/tabs');
|
||||
if (!fs.existsSync(tabsDir)) {
|
||||
warn('нет src/tabs', 'панель без вкладок покажет заглушку');
|
||||
} else {
|
||||
// Имена иконок берутся только из тел карт ICONS и coreIcons — разбирать
|
||||
// файл целиком значило бы ловить в известные и случайные `что-то: Ico…`,
|
||||
// а ложная ошибка здесь хуже пропущенной: buildTabs всё равно поймает
|
||||
// незарегистрированную иконку в рантайме.
|
||||
const mapBody = (text, name) => {
|
||||
const start = text.indexOf(`export const ${name}`);
|
||||
if (start === -1) return null;
|
||||
const open = text.indexOf('{', start), close = text.indexOf('\n};', open);
|
||||
return open === -1 || close === -1 ? null : text.slice(open, close);
|
||||
};
|
||||
const bodies = [mapBody(read(path.join(FRONT, 'src/components/icons.tsx')) ?? '', 'ICONS'),
|
||||
mapBody(read(path.join(BROWSER_CORE, 'icons/index.tsx')) ?? '', 'coreIcons')];
|
||||
const known = bodies.some(b => b === null)
|
||||
? null // карту не нашли — проверку пропускаем
|
||||
: new Set([...bodies.join('\n').matchAll(/(\w+)\s*:\s*Ico\w+/g)].map(m => m[1]));
|
||||
if (known === null) skip('сверка иконок вкладок — карта ICONS не разобрана');
|
||||
|
||||
const ids = new Map();
|
||||
let clean = true;
|
||||
|
||||
for (const e of fs.readdirSync(tabsDir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const dir = path.join(tabsDir, e.name);
|
||||
const metaPath = path.join(dir, 'metadata.json');
|
||||
if (!fs.existsSync(path.join(dir, 'index.tsx'))) { err(`tabs/${e.name}: нет index.tsx`); clean = false; continue; }
|
||||
if (!fs.existsSync(metaPath)) { err(`tabs/${e.name}: нет metadata.json`); clean = false; continue; }
|
||||
let meta;
|
||||
try { meta = JSON.parse(read(metaPath)); }
|
||||
catch { err(`tabs/${e.name}/metadata.json: не разбирается как JSON`); clean = false; continue; }
|
||||
for (const key of ['id', 'label', 'icon']) {
|
||||
if (typeof meta[key] !== 'string' || !meta[key].trim()) {
|
||||
err(`tabs/${e.name}/metadata.json: поле "${key}" должно быть непустой строкой`); clean = false;
|
||||
}
|
||||
}
|
||||
if (meta.id) {
|
||||
if (ids.has(meta.id)) { err(`дублирующийся id вкладки "${meta.id}": ${ids.get(meta.id)} и ${e.name}`); clean = false; }
|
||||
else ids.set(meta.id, e.name);
|
||||
}
|
||||
if (meta.icon && known && !known.has(meta.icon)) {
|
||||
err(`tabs/${e.name}: иконка "${meta.icon}" не найдена в карте ICONS`); clean = false;
|
||||
}
|
||||
}
|
||||
if (clean && ids.size) ok(`вкладки в порядке (${ids.size} шт.)`);
|
||||
}
|
||||
|
||||
// ─── 5. бэкенд ──────────────────────────────────────────────────────────────
|
||||
const serverTs = read(path.join(BACK, 'server.ts'));
|
||||
if (serverTs === null) {
|
||||
skip('server.ts не найден — панель, видимо, без своего бэкенда');
|
||||
} else {
|
||||
if (/from\s+'\.\/core'/.test(serverTs)) ok("server.ts импортирует './core'");
|
||||
else warn("server.ts не импортирует './core'", 'панель собирает сервер сама — это допустимо для панели-прокси');
|
||||
|
||||
const backPkgText = read(path.join(BACK, 'package.json'));
|
||||
if (backPkgText) {
|
||||
const deps = JSON.parse(backPkgText).dependencies ?? {};
|
||||
for (const dep of ['express', 'jsonwebtoken']) {
|
||||
if (deps[dep]) ok(`бэкенд: ${dep} в зависимостях`);
|
||||
else err(`backend/package.json: нет зависимости ${dep}`,
|
||||
'серверная половина ядра объявляет её peer-зависимостью');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. следы шаблона (предупреждения) ──────────────────────────────────────
|
||||
if (!IS_TEMPLATE) {
|
||||
const app = read(path.join(FRONT, 'src/App.tsx')) ?? '';
|
||||
if (/demo_token|demo_theme/.test(app)) {
|
||||
warn('storageKeys остались демонстрационными (demo_token/demo_theme)',
|
||||
'две панели на одном домене затрут друг другу токен и тему');
|
||||
}
|
||||
if (/name:\s*'Demo Panel'/.test(app)) warn("branding.name остался 'Demo Panel'");
|
||||
|
||||
// Заголовок вкладки браузера живёт отдельно от branding.name и виден до
|
||||
// того, как смонтируется React, — забывается чаще всего.
|
||||
const html = read(path.join(FRONT, 'index.html')) ?? '';
|
||||
if (/<title>Demo Panel<\/title>/.test(html)) warn("index.html: <title> остался 'Demo Panel'");
|
||||
|
||||
for (const [pkgPath, label] of [[path.join(FRONT, 'package.json'), 'frontend'],
|
||||
[path.join(BACK, 'package.json'), 'backend']]) {
|
||||
const t = read(pkgPath);
|
||||
if (t && /"ma7engine-example-/.test(t)) warn(`${label}/package.json: имя осталось от шаблона`);
|
||||
}
|
||||
}
|
||||
|
||||
// Половины ставят TypeScript и @types/node врозь — расхождение мажоров значит,
|
||||
// что tsc во фронте и в бэкенде проверяют код по разным правилам.
|
||||
{
|
||||
const major = (r) => (r ?? '').replace(/^[^\d]*/, '').split('.')[0];
|
||||
const f = frontPkg?.devDependencies ?? {};
|
||||
const bText = read(path.join(BACK, 'package.json'));
|
||||
const b = bText ? (JSON.parse(bText).devDependencies ?? {}) : {};
|
||||
for (const dep of ['typescript', '@types/node']) {
|
||||
if (f[dep] && b[dep] && major(f[dep]) !== major(b[dep])) {
|
||||
warn(`${dep} разъехался между половинами: ${f[dep]} и ${b[dep]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── вывод ──────────────────────────────────────────────────────────────────
|
||||
const rel = path.relative(process.cwd(), PANEL) || '.';
|
||||
console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ` (ядро: ${MODE})`}\n`);
|
||||
|
||||
for (const m of oks) console.log(` ok ${m}`);
|
||||
for (const m of skips) console.log(` — ${m}`);
|
||||
for (const m of warns) console.log(` warn ${m}`);
|
||||
for (const m of errors) console.log(` ОШИБКА ${m}`);
|
||||
|
||||
console.log();
|
||||
if (errors.length) {
|
||||
console.log(`doctor: ошибок ${errors.length}, предупреждений ${warns.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(warns.length
|
||||
? `doctor: ошибок нет, предупреждений ${warns.length}`
|
||||
: 'doctor: панель соответствует стандарту');
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Заводит новую панель на этом ядре — копирует обе половины шаблона и
|
||||
// подставляет то, что README раньше предписывал править руками: имена в
|
||||
// package.json, брендинг и ключи localStorage. Забытые ключи — не мелочь: две
|
||||
// панели на одном домене затирают друг другу токен и тему.
|
||||
//
|
||||
// npm run new-panel -- admin-hub # → ../admin-hub
|
||||
// npm run new-panel -- admin-hub ~/work/hub # явный путь
|
||||
//
|
||||
// По умолчанию панель кладётся соседом ma7engine: именно там её найдёт перебор
|
||||
// кандидатов в scripts/update.sh, и `npm run update` заработает без $MA7ENGINE.
|
||||
//
|
||||
// Не sh, в отличие от update.sh: здесь многострочная правка исходников, а не
|
||||
// копирование каталогов, и sed по номерам строк ломается от любой правки
|
||||
// шаблона. Node гарантированно есть — обе половины и так npm-проекты.
|
||||
import fs from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.dirname(HERE); // …/ma7engine
|
||||
|
||||
const die = (msg) => { console.error(`new-panel: ${msg}`); process.exit(1); };
|
||||
|
||||
// ─── аргументы ──────────────────────────────────────────────────────────────
|
||||
const [slug, dirArg] = process.argv.slice(2);
|
||||
|
||||
if (!slug || slug === '--help' || slug === '-h') {
|
||||
console.log('Использование: npm run new-panel -- <slug> [каталог]\n' +
|
||||
' slug — имя панели строчными буквами через дефис, например admin-hub');
|
||||
process.exit(slug ? 0 : 1);
|
||||
}
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
|
||||
die(`slug "${slug}" негоден — строчные латинские буквы, цифры и дефис, начинается с буквы`);
|
||||
}
|
||||
|
||||
const target = path.resolve(dirArg ?? path.join(path.dirname(ROOT), slug));
|
||||
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) {
|
||||
die(`каталог ${target} существует и не пуст`);
|
||||
}
|
||||
|
||||
// admin-hub → admin_hub (ключи стораджа) и «Admin Hub» (брендинг)
|
||||
const keyPrefix = slug.replace(/-/g, '_');
|
||||
const display = slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
|
||||
|
||||
// ─── копирование половин ────────────────────────────────────────────────────
|
||||
// Вендоренная копия ядра не копируется: она приедет своим `npm run update`.
|
||||
// Так новая панель гарантированно получает актуальное ядро, а не слепок чужого.
|
||||
const SKIP = new Set(['node_modules', 'dist', 'core', 'package-lock.json']);
|
||||
|
||||
function copyTree(from, to) {
|
||||
fs.mkdirSync(to, { recursive: true });
|
||||
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const src = path.join(from, entry.name);
|
||||
const dst = path.join(to, entry.name);
|
||||
if (entry.isDirectory()) copyTree(src, dst);
|
||||
else fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
copyTree(path.join(ROOT, 'frontend'), path.join(target, 'frontend'));
|
||||
copyTree(path.join(ROOT, 'backend'), path.join(target, 'backend'));
|
||||
|
||||
// ─── подстановки ────────────────────────────────────────────────────────────
|
||||
// Каждая замена обязана сработать: молча пропущенная — это ровно тот случай,
|
||||
// когда панель уезжает с чужим ключом стораджа и ловится потом руками.
|
||||
function edit(relPath, pairs) {
|
||||
const file = path.join(target, relPath);
|
||||
let text = fs.readFileSync(file, 'utf8');
|
||||
for (const [from, to] of pairs) {
|
||||
if (!text.includes(from)) die(`в ${relPath} не найдено для замены:\n---\n${from}\n---`);
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
fs.writeFileSync(file, text);
|
||||
}
|
||||
|
||||
edit('frontend/package.json', [
|
||||
['"ma7engine-example-frontend"', `"${slug}-frontend"`],
|
||||
// В панели ядро приезжает через git subtree, а не копированием: хуки
|
||||
// predev/prebuild и сам update.sh здесь не нужны и вредны — update.sh
|
||||
// сносит каталог целиком (rm -rf) и затёр бы подтянутое поддерево.
|
||||
[` "predev": "npm run update",\n`, ''],
|
||||
[` "update": "sh scripts/update.sh",\n`, ''],
|
||||
[` "prebuild": "npm run update",\n`, ''],
|
||||
]);
|
||||
// Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно
|
||||
// до того, как React смонтируется, и в закладках.
|
||||
edit('frontend/index.html', [['<title>Demo Panel</title>', `<title>${display}</title>`]]);
|
||||
edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]);
|
||||
|
||||
edit('frontend/src/App.tsx', [
|
||||
[`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
|
||||
// ../backend; ядро в свою панель приезжает через \`npm run update\`.
|
||||
//
|
||||
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
|
||||
// меню по правам и переключение вкладок — в <Panel />.
|
||||
//
|
||||
// Парная половина — backend/server.ts: вкладки ниже ходят в его
|
||||
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.`,
|
||||
`// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема,
|
||||
// drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок —
|
||||
// в <Panel /> из ядра.
|
||||
//
|
||||
// Ядро приезжает копией в src/core через \`npm run update\` (его зовут predev
|
||||
// и prebuild). Парная половина — backend/server.ts.`],
|
||||
|
||||
[`branding: { name: 'Demo Panel', version: '0.1.0' },`,
|
||||
`branding: { name: '${display}', version: '0.1.0' },`],
|
||||
|
||||
[`storageKeys: { token: 'demo_token', theme: 'demo_theme' },`,
|
||||
`storageKeys: { token: '${keyPrefix}_token', theme: '${keyPrefix}_theme' },`],
|
||||
|
||||
[` // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },`,
|
||||
` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : <ServerBar /> },`],
|
||||
]);
|
||||
|
||||
// ─── бэкенд: шапка, лог и вырезание демо-маршрутов ──────────────────────────
|
||||
const serverFile = path.join(target, 'backend/server.ts');
|
||||
let server = fs.readFileSync(serverFile, 'utf8');
|
||||
|
||||
const headOld = `// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
|
||||
// ../frontend; ядро в свою панель приезжает через \`npm run update\`.
|
||||
//
|
||||
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
|
||||
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.`;
|
||||
const headNew = `// Бэкенд панели ${display}. Всё, что здесь есть: конфиг и свои маршруты.
|
||||
// Логин, сессия, права, рейт-лимит, раздача SPA и порядок их монтирования —
|
||||
// в createPanelServer. Ядро приезжает копией в ./core через \`npm run update\`,
|
||||
// запущенный во фронте.`;
|
||||
if (!server.includes(headOld)) die('backend/server.ts: не найдена шапка шаблона');
|
||||
server = server.replace(headOld, headNew);
|
||||
|
||||
// mountRoutes с демо-маршрутами уезжает целиком: три роута /api/demo/* нужны
|
||||
// только вкладкам hello и secrets, которых в новой панели не будет. Образец
|
||||
// остаётся комментарием — вместе со строкой импорта, которая ему понадобится.
|
||||
const startMark = '\n mountRoutes(app, { auth, forwardError }) {\n';
|
||||
const endMark = '\n },\n});\n';
|
||||
const start = server.indexOf(startMark);
|
||||
const end = server.indexOf(endMark, start);
|
||||
if (start === -1 || end === -1) die('backend/server.ts: не найден блок mountRoutes');
|
||||
|
||||
server = server.slice(0, start) + `
|
||||
// Маршруты панели. Монтируются ядром после логина и до статики — порядок
|
||||
// зафиксирован внутри createPanelServer и снаружи не нарушается.
|
||||
//
|
||||
// import type { Request, Response } from 'express';
|
||||
//
|
||||
// mountRoutes(app, { auth, forwardError }) {
|
||||
// // Открыт любому залогиненному — у вкладки нет permission.
|
||||
// app.get('/api/reports', auth.requireAuth, async (req: Request, res: Response) => {
|
||||
// try { res.json(await db.reports()); }
|
||||
// catch (e) { forwardError(res, e); }
|
||||
// });
|
||||
//
|
||||
// // Закрыт правом. Гейт на фронте (permission во вкладке) — это
|
||||
// // только UX; настоящая граница здесь.
|
||||
// app.post('/api/reports', auth.requireAuth,
|
||||
// auth.requirePermission('reports.manage'), handler);
|
||||
// },
|
||||
});
|
||||
` + server.slice(end + endMark.length);
|
||||
|
||||
// Request/Response остались бы висеть неиспользованными — образец выше
|
||||
// показывает, как вернуть импорт вместе с маршрутом.
|
||||
const importLine = "import type { Request, Response } from 'express';\n";
|
||||
if (server.includes(importLine)) server = server.replace(importLine, '');
|
||||
|
||||
if (!server.includes('demo-panel: listening on')) die('backend/server.ts: не найдена строка лога');
|
||||
server = server.replace('demo-panel: listening on', `${slug}: listening on`);
|
||||
|
||||
fs.writeFileSync(serverFile, server);
|
||||
|
||||
// ─── демо-вкладки ───────────────────────────────────────────────────────────
|
||||
// hello и secrets ходят в /api/demo/*, которых больше нет — уезжают. docs
|
||||
// (справочник по ядру) и kit (живой каталог классов дизайн-системы)
|
||||
// самодостаточны и остаются: новая панель запускается непустой.
|
||||
for (const tab of ['hello', 'secrets']) {
|
||||
fs.rmSync(path.join(target, 'frontend/src/tabs', tab), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── файлы уровня панели ────────────────────────────────────────────────────
|
||||
// Копия ядра в панели — закоммиченный исходник, а не артефакт сборки: образ
|
||||
// собирается из чистого checkout'а, где ma7engine рядом нет. Обновляется она
|
||||
// осознанно, через `npm run update`, и приходит в ревью диффом; расхождение с
|
||||
// ядром ловит `npm run doctor`.
|
||||
fs.writeFileSync(path.join(target, '.gitignore'),
|
||||
`node_modules/
|
||||
|
||||
# Сборка фронта — в образе он собирается заново.
|
||||
frontend/dist
|
||||
|
||||
# frontend/src/core и backend/core здесь НЕ игнорируются намеренно: копия ядра
|
||||
# едет в репозиторий панели, иначе образ не собрать без ma7engine рядом.
|
||||
`);
|
||||
|
||||
// Dockerfile и .dockerignore едут из шаблона как есть: контекст сборки — каталог
|
||||
// панели, поэтому ни одного пути в них править не нужно.
|
||||
for (const f of ['Dockerfile', '.dockerignore']) {
|
||||
fs.copyFileSync(path.join(ROOT, f), path.join(target, f));
|
||||
}
|
||||
|
||||
// Доктор берётся из scripts/, а не тот, что мог приехать внутри frontend/:
|
||||
// в шаблоне frontend/scripts/doctor.mjs — вендоренная копия, и она бывает
|
||||
// старше исходника. Панель должна получить актуальные проверки.
|
||||
fs.mkdirSync(path.join(target, 'frontend/scripts'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts/doctor.mjs'),
|
||||
path.join(target, 'frontend/scripts/doctor.mjs'));
|
||||
|
||||
fs.mkdirSync(path.join(target, 'scripts'), { recursive: true });
|
||||
const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8')
|
||||
.replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.',
|
||||
`# Поднимает панель ${display} целиком: бэкенд в фоне, Vite на переднем плане.`)
|
||||
.replace('# npm run demo (из корня ma7engine)\n', '')
|
||||
.replace('# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)',
|
||||
'# sh scripts/dev.sh (откуда угодно — пути считаются от самого скрипта)')
|
||||
.replace('# Учётка по умолчанию — admin / demo. Переопределяется окружением:\n' +
|
||||
'# ADMIN_USER=root ADMIN_PASS=secret npm run demo',
|
||||
'# Учётка по умолчанию — admin / demo, только для дева. Переопределяется\n' +
|
||||
'# окружением: ADMIN_USER=root ADMIN_PASS=secret sh scripts/dev.sh')
|
||||
.replaceAll('demo: ', `${slug}: `);
|
||||
fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 });
|
||||
|
||||
// ─── ядро ───────────────────────────────────────────────────────────────────
|
||||
// Ядро сюда не копируется: его приносит `git subtree add` — командами ниже.
|
||||
// Копия и поддерево не уживаются, поэтому update.sh в панель не едет вовсе.
|
||||
fs.rmSync(path.join(target, 'frontend/scripts/update.sh'), { force: true });
|
||||
|
||||
// ─── что дальше ─────────────────────────────────────────────────────────────
|
||||
// Панель — надстройка: она обычно лежит подкаталогом продуктового репозитория,
|
||||
// и prefix для subtree считается от его корня. Угадать корень отсюда нельзя,
|
||||
// поэтому в подсказке путь даётся относительно каталога панели.
|
||||
const prefix = `${path.basename(target)}/`;
|
||||
console.log(`
|
||||
new-panel: панель ${display} создана в ${target}
|
||||
|
||||
Дальше:
|
||||
cd ${target}/frontend && npm i
|
||||
cd ${target}/backend && npm i
|
||||
cd ${target} && sh scripts/dev.sh # обе половины, фронт на :5173
|
||||
|
||||
Пароль и секрет обязательны — без них ядро не даст процессу подняться:
|
||||
ADMIN_PASS=... ADMIN_JWT_SECRET=...
|
||||
|
||||
Ядро приносит git subtree — из каталога панели в её репозитории:
|
||||
git subtree add --prefix=${prefix}frontend/src/core ${ROOT} dist/browser --squash
|
||||
git subtree add --prefix=${prefix}backend/core ${ROOT} dist/server --squash
|
||||
|
||||
Дальше обновляется тем же git subtree pull. Копировать ядро руками не нужно —
|
||||
и не стоит: поддерево умеет merge и покажет конфликт, а копия молча затрёт.
|
||||
|
||||
Проверить, что панель собрана по стандарту:
|
||||
cd ${target}/frontend && npm run doctor`);
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Готовит ветки, из которых продукты тянут ядро через `git subtree pull`.
|
||||
#
|
||||
# sh scripts/release.sh (или npm run release)
|
||||
#
|
||||
# У ядра две половины и два адресата, а `git subtree` работает с одним prefix
|
||||
# за раз — поэтому каждая половина выделяется в свою ветку с собственной
|
||||
# историей:
|
||||
#
|
||||
# core/browser → ветка dist/browser → <панель>/frontend/src/core
|
||||
# core/server → ветка dist/server → <панель>/backend/core
|
||||
#
|
||||
# `git subtree split` переписывает историю выбранного каталога так, будто он
|
||||
# всегда был корнем репозитория. Ветки полностью производные: их не правят
|
||||
# руками и не мержат обратно — правится core/, после чего скрипт гоняется снова.
|
||||
#
|
||||
# Почему subtree, а не копирование: копия затирает любую локальную правку и не
|
||||
# оставляет истории, поэтому «ядро в панели разошлось» выясняется постфактум.
|
||||
# subtree делает настоящий merge — расхождение видно конфликтом в момент
|
||||
# обновления, а правку, сделанную в продукте, можно отдать обратно в ядро
|
||||
# (`git subtree push --prefix=<путь> <ядро> <ветка>`).
|
||||
set -eu
|
||||
|
||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root=$(dirname "$here")
|
||||
cd "$root"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "release: рабочее дерево грязное — закоммить или спрячь правки" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `git subtree split -b <ветка>` умеет только создать ветку и отказывается
|
||||
# двигать существующую, если новый коммит ей не потомок, — а после любого
|
||||
# reset/rebase в ядре это ровно так. Поэтому split считает коммит, а ветка
|
||||
# переставляется принудительно: ветки производные, своей истории у них нет.
|
||||
split() { # split <каталог> <ветка>
|
||||
printf 'release: %s → %s\n' "$1" "$2"
|
||||
rev=$(git subtree split --prefix="$1" -q)
|
||||
git branch -f "$2" "$rev"
|
||||
printf ' %s\n' "$(git log -1 --format='%h %s' "$2")"
|
||||
}
|
||||
|
||||
split core/browser dist/browser
|
||||
split core/server dist/server
|
||||
|
||||
cat <<TXT
|
||||
|
||||
Готово. В продукте, где панель лежит подкаталогом:
|
||||
|
||||
# первый раз
|
||||
git subtree add --prefix=<панель>/frontend/src/core $root dist/browser --squash
|
||||
git subtree add --prefix=<панель>/backend/core $root dist/server --squash
|
||||
|
||||
# дальше
|
||||
git subtree pull --prefix=<панель>/frontend/src/core $root dist/browser --squash
|
||||
git subtree pull --prefix=<панель>/backend/core $root dist/server --squash
|
||||
TXT
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Обновляет вендоренную копию ядра (ma7engine) в панели:
|
||||
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
|
||||
# ma7engine/core/server → <панель>/backend/core (серверная половина)
|
||||
#
|
||||
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
|
||||
# как в awg-ui), получает серверную часть в <панель>/core.
|
||||
#
|
||||
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
|
||||
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
|
||||
# уходящий выше него, внутри контейнера не разрешится. При копии пути
|
||||
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
|
||||
# трогать не нужно вообще.
|
||||
#
|
||||
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
|
||||
# вызывать его нужно и тогда, когда ma7engine рядом нет.
|
||||
#
|
||||
# Использование: npm run update (или sh scripts/update.sh)
|
||||
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
|
||||
# существующий выигрывает, выбранный путь печатается.
|
||||
set -eu
|
||||
|
||||
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
|
||||
|
||||
here=$(abspath "$(dirname -- "$0")")
|
||||
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
|
||||
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
|
||||
|
||||
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
|
||||
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
|
||||
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
|
||||
# молча уводит синхронизацию на старую копию.
|
||||
#
|
||||
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
|
||||
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
|
||||
# именно от неё и уезжает синхронизация молча).
|
||||
CANDIDATES="
|
||||
$app/../ma7engine
|
||||
$app/../../ma7engine
|
||||
$app/../../../ma7engine
|
||||
$app/../../../admins/ma7engine
|
||||
"
|
||||
|
||||
find_engine() {
|
||||
if [ -n "${MA7ENGINE:-}" ]; then
|
||||
if [ ! -d "$MA7ENGINE/core/browser" ]; then
|
||||
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
|
||||
exit 1
|
||||
fi
|
||||
abspath "$MA7ENGINE"
|
||||
return
|
||||
fi
|
||||
for candidate in $CANDIDATES; do
|
||||
if [ -d "$candidate/core/browser" ]; then
|
||||
abspath "$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
|
||||
server_target() {
|
||||
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
|
||||
elif [ -f "$app/server.ts" ]; then echo "$app/core"
|
||||
fi
|
||||
}
|
||||
|
||||
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
|
||||
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
|
||||
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
|
||||
if [ ! -f "$app/index.html" ]; then
|
||||
echo "update: $app не похож на фронт панели (нет index.html)." >&2
|
||||
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
front_to=$app/src/core
|
||||
server_to=$(server_target)
|
||||
|
||||
engine=$(find_engine)
|
||||
|
||||
if [ -z "$engine" ]; then
|
||||
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
|
||||
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
|
||||
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
|
||||
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
|
||||
exit 0
|
||||
fi
|
||||
echo "update: ma7engine не найден ни в одном из мест:" >&2
|
||||
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
|
||||
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "update: ядро — $engine"
|
||||
|
||||
copy() { # copy <откуда> <куда> <подпись>
|
||||
[ -d "$1" ] || return 0
|
||||
rm -rf "$2"
|
||||
mkdir -p "$(dirname "$2")"
|
||||
cp -R "$1" "$2"
|
||||
echo " $3: $1 → $2"
|
||||
}
|
||||
|
||||
copy "$engine/core/browser" "$front_to" "фронт"
|
||||
if [ -n "$server_to" ]; then
|
||||
copy "$engine/core/server" "$server_to" "сервер"
|
||||
fi
|
||||
|
||||
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
|
||||
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
|
||||
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
|
||||
if [ -f "$engine/scripts/doctor.mjs" ]; then
|
||||
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
|
||||
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
|
||||
fi
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["core/browser"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["core/server"]
|
||||
}
|
||||
Reference in New Issue
Block a user