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:
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "docs",
|
||||
"label": "Инструкция",
|
||||
"icon": "book",
|
||||
"order": 5
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "hello",
|
||||
"label": "Кто я",
|
||||
"icon": "star",
|
||||
"order": 10
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "kit",
|
||||
"label": "Витрина",
|
||||
"icon": "grid",
|
||||
"order": 30
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "secrets",
|
||||
"label": "Секреты",
|
||||
"icon": "key",
|
||||
"permission": "secrets.view",
|
||||
"order": 20
|
||||
}
|
||||
Reference in New Issue
Block a user