ma7engine: ядро, шаблон панели, скаффолдер и доктор

Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
/* Стили панели поверх дизайн-системы ядра (@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;
}
+36
View File
@@ -0,0 +1,36 @@
// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../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} />;
}
+33
View File
@@ -0,0 +1,33 @@
// Иконки панели. Общие приезжают из ядра и реэкспортятся отсюда, чтобы вкладки
// брали все иконки из одного места; здесь остаётся только своё.
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,
};
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
/* Стили только этой вкладки. Всё, что можно было взять из дизайн-системы ядра
(.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;
}
+461
View File
@@ -0,0 +1,461 @@
// Инструкция к коду панели — прямо в панели. Не пересказ 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>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "docs",
"label": "Инструкция",
"icon": "book",
"order": 5
}
+62
View File
@@ -0,0 +1,62 @@
// Вкладка без 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>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "hello",
"label": "Кто я",
"icon": "star",
"order": 10
}
+15
View File
@@ -0,0 +1,15 @@
// Реестр вкладок. Сборка (валидация 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,
);
+151
View File
@@ -0,0 +1,151 @@
// Витрина дизайн-системы ядра: все классы публичного контракта из
// @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>
)}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
/* Стили только этой вкладки — живут в её папке и импортятся её же компонентом.
Всё, что показано ниже, нарисовано классами ядра; здесь только раскладка
витрины и подписи с именами классов. */
.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;
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "kit",
"label": "Витрина",
"icon": "grid",
"order": 30
}
+36
View File
@@ -0,0 +1,36 @@
// Вкладка с 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>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "secrets",
"label": "Секреты",
"icon": "key",
"permission": "secrets.view",
"order": 20
}