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:
+150
@@ -0,0 +1,150 @@
|
||||
// Реестр вкладок. Каждая вкладка — самодостаточная папка 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user