elcrm 1.1.16 → 1.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4 -2
- package/dist/vite/discover-lib.js +3 -3
- package/dist/vite/plugin-css-scoped.d.ts +3 -0
- package/dist/vite/plugin-css-scoped.js +42 -10
- package/package.json +1 -1
- package/templates/elcrm-cursor/rules/elcrm-ui.mdc +1 -1
- package/templates/elcrm-docs/CLI.elCRM.md +1 -1
- package/templates/elcrm-docs/COMPONENTS.elCRM.md +9 -3
- package/templates/elcrm-docs/MODAL.elCRM.md +22 -3
- package/templates/elcrm-docs/ROUTER.elCRM.md +9 -5
- package/templates/orbit/README.md +4 -3
- package/templates/orbit/api/package.json +18 -0
- package/templates/orbit/api/src/config.ts +49 -0
- package/templates/orbit/api/src/index.ts +72 -0
- package/templates/orbit/api/src/lib/cron.ts +13 -0
- package/templates/orbit/api/src/lib/db.ts +53 -0
- package/templates/orbit/api/src/lib/jwt.ts +19 -0
- package/templates/orbit/api/src/routes/auth.ts +114 -0
- package/templates/orbit/api/src/routes/deploy.ts +16 -0
- package/templates/orbit/api/src/routes/index.ts +16 -0
- package/templates/orbit/package.json +8 -30
- package/templates/orbit/scripts/dev.ts +33 -0
- package/templates/orbit/web/.env.example +3 -0
- package/templates/orbit/web/package.json +40 -0
- package/templates/orbit/{src → web/src}/App.tsx +1 -9
- package/templates/orbit/{src → web/src}/component/Nav.tsx +15 -15
- package/templates/orbit/web/src/icons.generated.tsx +76 -0
- package/templates/orbit/{src → web/src}/main.tsx +10 -7
- package/templates/orbit/{src → web/src}/modules/Home/index.tsx +9 -9
- package/templates/orbit/web/src/pages/Auth.tsx +71 -0
- package/templates/orbit/{src → web/src}/pages/Main.tsx +7 -10
- package/templates/orbit/web/src/state.ts +53 -0
- package/templates/orbit/{src → web/src}/style/theme-dark.css +5 -0
- package/templates/orbit/{src → web/src}/style/theme-light.css +5 -0
- package/templates/orbit/{src → web/src}/style/theme.css +15 -0
- package/templates/orbit/web/src/vite-env.d.ts +11 -0
- package/templates/orbit/web/vite.config.ts +23 -0
- package/templates/panel/web/package.json +1 -1
- package/templates/panel/web/src/App.tsx +1 -9
- package/templates/panel/web/src/main.tsx +1 -14
- package/templates/panel/web/src/pages/Main.tsx +1 -9
- package/templates/panel/web/src/style/theme-dark.css +10 -0
- package/templates/panel/web/src/style/theme-light.css +10 -0
- package/templates/panel/web/src/style/theme.css +26 -0
- package/templates/orbit/src/lazyRoute.ts +0 -4
- package/templates/orbit/src/lib/icon.tsx +0 -48
- package/templates/orbit/src/pages/Auth.tsx +0 -45
- package/templates/orbit/src/state.ts +0 -28
- package/templates/orbit/src/vite-env.d.ts +0 -1
- package/templates/orbit/vite.config.ts +0 -14
- package/templates/panel/web/src/lazyRoute.ts +0 -10
- /package/templates/orbit/{index.html → web/index.html} +0 -0
- /package/templates/orbit/{src → web/src}/lib/theme.ts +0 -0
- /package/templates/orbit/{src → web/src}/modules/Analytics/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Catalog/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Customers/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Finances/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Integrations/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Link/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Marketing/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Placeholder.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Settings/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/modules/Store/index.tsx +0 -0
- /package/templates/orbit/{src → web/src}/router-data.d.ts +0 -0
- /package/templates/orbit/{src → web/src}/style/elcrm.css +0 -0
- /package/templates/orbit/{tsconfig.json → web/tsconfig.json} +0 -0
package/dist/index.js
CHANGED
|
@@ -3512,7 +3512,7 @@ function copyTemplate(templateName, targetDir, packageName) {
|
|
|
3512
3512
|
cpSync(from, to, { recursive: true });
|
|
3513
3513
|
}
|
|
3514
3514
|
const name = packageName ?? folderToPackageName(targetDir);
|
|
3515
|
-
if (templateName === "panel") {
|
|
3515
|
+
if (templateName === "panel" || templateName === "orbit") {
|
|
3516
3516
|
finalizePanelNames(targetDir, name);
|
|
3517
3517
|
return;
|
|
3518
3518
|
}
|
|
@@ -3542,11 +3542,13 @@ function doneHint(template, cdPath) {
|
|
|
3542
3542
|
` : "";
|
|
3543
3543
|
if (template === "orbit") {
|
|
3544
3544
|
console.log(`
|
|
3545
|
-
\u0413\u043E\u0442\u043E\u0432\u043E (orbit:
|
|
3545
|
+
\u0413\u043E\u0442\u043E\u0432\u043E (orbit: web + api).
|
|
3546
3546
|
|
|
3547
3547
|
${prefix} bun run dev
|
|
3548
3548
|
|
|
3549
3549
|
Web \u2192 http://localhost:5173
|
|
3550
|
+
API \u2192 http://localhost:4545
|
|
3551
|
+
\u0412\u0445\u043E\u0434: admin / admin
|
|
3550
3552
|
`);
|
|
3551
3553
|
return;
|
|
3552
3554
|
}
|
|
@@ -11,8 +11,8 @@ function assignLetters(names, overrides) {
|
|
|
11
11
|
const used = new Set;
|
|
12
12
|
const letters = {};
|
|
13
13
|
for (const [name, letter] of Object.entries(overrides)) {
|
|
14
|
-
if (!/^[a-z0-
|
|
15
|
-
throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-
|
|
14
|
+
if (!/^[a-z0-9A-Z]$/.test(letter)) {
|
|
15
|
+
throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9A-Z)`);
|
|
16
16
|
}
|
|
17
17
|
if (used.has(letter)) {
|
|
18
18
|
throw new Error(`[discover-lib] дубль буквы "${letter}" в overrides`);
|
|
@@ -25,7 +25,7 @@ function assignLetters(names, overrides) {
|
|
|
25
25
|
if (letters[name])
|
|
26
26
|
continue;
|
|
27
27
|
const base = name.replace(/[^a-zA-Z]/g, "").toLowerCase();
|
|
28
|
-
let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."
|
|
28
|
+
let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"].find((c) => !used.has(c));
|
|
29
29
|
if (!letter) {
|
|
30
30
|
throw new Error(`[discover-lib] нет свободной буквы/цифры для "${name}"`);
|
|
31
31
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vite helper: короткие unique className для CSS Modules elCRM.
|
|
3
|
+
* Формат: 3 символа `{prefix}{буква}{класс}`.
|
|
3
4
|
* @see createCssScoped
|
|
4
5
|
*/
|
|
5
6
|
export type CssScopedOptions = {
|
|
6
7
|
/** Первая буква пакета (c = components, b = button, …) */
|
|
7
8
|
prefix: string;
|
|
9
|
+
/** Свой prefix у компонента, если слотов пакета не хватает */
|
|
10
|
+
prefixOverrides?: Record<string, string>;
|
|
8
11
|
/** Папка компонента → одна буква */
|
|
9
12
|
components: Record<string, string>;
|
|
10
13
|
/** Каталог с папками компонентов (абсолютный путь) */
|
|
@@ -15,15 +15,22 @@ function listModuleCss(dir) {
|
|
|
15
15
|
}
|
|
16
16
|
function localClasses(css) {
|
|
17
17
|
const set = new Set;
|
|
18
|
-
for (const m of css.matchAll(/(?:^|[,{\s>+~])\.([a-
|
|
19
|
-
set.add(m[1]);
|
|
18
|
+
for (const m of css.matchAll(/(?:^|[,{\s>+~])\.([a-z0-9])\b/gi)) {
|
|
19
|
+
set.add(m[1].toLowerCase());
|
|
20
20
|
}
|
|
21
21
|
return [...set];
|
|
22
22
|
}
|
|
23
|
+
function assertPrefix(value, label) {
|
|
24
|
+
if (!/^[a-z]$/.test(value)) {
|
|
25
|
+
throw new Error(`[css-scoped] ${label} должен быть одной буквой a-z, получено: "${value}"`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
23
28
|
function createCssScoped(options) {
|
|
24
29
|
const prefix = options.prefix;
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
assertPrefix(prefix, "prefix");
|
|
31
|
+
const prefixOverrides = options.prefixOverrides ?? {};
|
|
32
|
+
for (const [name, pfx] of Object.entries(prefixOverrides)) {
|
|
33
|
+
assertPrefix(pfx, `prefixOverrides["${name}"]`);
|
|
27
34
|
}
|
|
28
35
|
const components = options.components;
|
|
29
36
|
const libDir = options.libDir;
|
|
@@ -37,19 +44,25 @@ function createCssScoped(options) {
|
|
|
37
44
|
}
|
|
38
45
|
return m[1];
|
|
39
46
|
}
|
|
47
|
+
function prefixFor(component) {
|
|
48
|
+
return prefixOverrides[component] ?? prefix;
|
|
49
|
+
}
|
|
40
50
|
function generateScopedName(name, filename) {
|
|
41
|
-
if (!/^[a-
|
|
42
|
-
throw new Error(`[css-scoped] локальный класс
|
|
51
|
+
if (!/^[a-z0-9]$/.test(name)) {
|
|
52
|
+
throw new Error(`[css-scoped] локальный класс — 1 знак [a-z0-9] (scoped = 3 символа): ".${name}" (${filename})`);
|
|
43
53
|
}
|
|
44
54
|
const component = componentFromFilename(filename);
|
|
45
55
|
const letter = components[component];
|
|
46
56
|
if (!letter) {
|
|
47
57
|
throw new Error(`[css-scoped] нет буквы для компонента "${component}". Добавьте в components.`);
|
|
48
58
|
}
|
|
49
|
-
if (!/^[a-z0-
|
|
50
|
-
throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-
|
|
59
|
+
if (!/^[a-z0-9A-Z]$/.test(letter)) {
|
|
60
|
+
throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9A-Z: "${letter}"`);
|
|
61
|
+
}
|
|
62
|
+
const scoped = `${prefixFor(component)}${letter}${name}`;
|
|
63
|
+
if (scoped.length !== 3) {
|
|
64
|
+
throw new Error(`[css-scoped] "${scoped}" ≠ 3 символа (${component}.${name})`);
|
|
51
65
|
}
|
|
52
|
-
const scoped = `${prefix}${letter}${name}`;
|
|
53
66
|
const owner = `${component}.${name}`;
|
|
54
67
|
const prev = registry.get(scoped);
|
|
55
68
|
if (prev && prev !== owner) {
|
|
@@ -72,6 +85,10 @@ function createCssScoped(options) {
|
|
|
72
85
|
if (missingMap.length) {
|
|
73
86
|
throw new Error(`[css-scoped] нет буквы в components: ${missingMap.join(", ")}`);
|
|
74
87
|
}
|
|
88
|
+
const unknownOverride = Object.keys(prefixOverrides).filter((n) => !mapped.has(n));
|
|
89
|
+
if (unknownOverride.length) {
|
|
90
|
+
throw new Error(`[css-scoped] prefixOverrides без компонента: ${unknownOverride.join(", ")}`);
|
|
91
|
+
}
|
|
75
92
|
const letters = Object.values(components);
|
|
76
93
|
const dupLetter = [
|
|
77
94
|
...new Set(letters.filter((l, i) => letters.indexOf(l) !== i))
|
|
@@ -79,15 +96,30 @@ function createCssScoped(options) {
|
|
|
79
96
|
if (dupLetter.length) {
|
|
80
97
|
throw new Error(`[css-scoped] дубли букв компонентов: ${dupLetter.join(", ")}`);
|
|
81
98
|
}
|
|
99
|
+
const namespaces = Object.entries(components).map(([name, letter]) => `${prefixFor(name)}${letter}`);
|
|
100
|
+
const dupNs = [
|
|
101
|
+
...new Set(namespaces.filter((n, i) => namespaces.indexOf(n) !== i))
|
|
102
|
+
];
|
|
103
|
+
if (dupNs.length) {
|
|
104
|
+
throw new Error(`[css-scoped] дубли пространства {prefix}{буква}: ${dupNs.join(", ")}`);
|
|
105
|
+
}
|
|
82
106
|
const files = listModuleCss(libDir);
|
|
83
107
|
for (const file of files) {
|
|
84
108
|
const css = readFileSync(file, "utf8");
|
|
109
|
+
const long = [
|
|
110
|
+
...css.matchAll(/(?:^|[,{\s>+~])\.([a-z][a-z0-9]+)\b/gi)
|
|
111
|
+
].map((m) => m[1]);
|
|
112
|
+
if (long.length) {
|
|
113
|
+
const component = componentFromFilename(file);
|
|
114
|
+
throw new Error(`[css-scoped] ${component}: класс длиннее 1 знака (${[...new Set(long)].join(", ")}). Сократи или вынеси в prefixOverrides + 1 знак.`);
|
|
115
|
+
}
|
|
85
116
|
for (const local of localClasses(css)) {
|
|
86
117
|
generateScopedName(local, file);
|
|
87
118
|
}
|
|
88
119
|
}
|
|
89
120
|
const snap = snapshot();
|
|
90
|
-
|
|
121
|
+
const extra = Object.entries(prefixOverrides).map(([n, p]) => `${n}=${p}`).join(", ");
|
|
122
|
+
console.log(`[css-scoped] OK: ${files.length} module.css, ${snap.size} классов (prefix=${prefix}${extra ? `; ${extra}` : ""})`);
|
|
91
123
|
return snap;
|
|
92
124
|
}
|
|
93
125
|
function plugin() {
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ src/modules/Name/
|
|
|
25
25
|
src/style/theme*.css + elcrm.css
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Orbit: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
|
|
28
|
+
Orbit: `web/` + `api/`. UI: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
|
|
29
29
|
|
|
30
30
|
- Кнопки: `@elcrm/button` (`onSend`). Иконки: `Icons.Line`. Подсказки: `@elcrm/tooltip`.
|
|
31
31
|
- HTTP: `Api.query("auth/pass", body)`. Роутер: `Router.setRouter` / `mergeRouter`, разные ключи вкладок.
|
|
@@ -111,7 +111,7 @@ minifyCssVars({ var: 2 })
|
|
|
111
111
|
|
|
112
112
|
Короткие имена **уникальны и подряд**: `--aa`, `--ab`, `--ac`… Занятые слоты пропускаются; `--field` не затирает `--field-border`. Чанки JSZip не минифицируются (декремент `--n`). Если слотов нет — сборка падает.
|
|
113
113
|
|
|
114
|
-
Также: `elcrm/vite/plugin-css-scoped`, `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
|
|
114
|
+
Также: `elcrm/vite/plugin-css-scoped` (`prefix` + `prefixOverrides`, scoped = 3 знака), `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
|
|
115
115
|
|
|
116
116
|
### Прочее
|
|
117
117
|
|
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
Layout, Header, Footer, Brand, Avatar, AvatarName, AvatarGroup, Badge,
|
|
16
16
|
Menu, Dropdown, Section, NavSections, TabSections, Breadcrumb, Stack,
|
|
17
17
|
Card, Stat, ActionGroup, TextGroup, ItemColumn, List, Item, PageHead, PageShell, ChatList, ChatMessages, ChatSplit, Sidebar, Popover,
|
|
18
|
+
Healthmap, PanelInfo,
|
|
18
19
|
Loading, EmptyState, RadioGroup, PricingGroup, PricingTable,
|
|
19
20
|
createLazyResolver,
|
|
20
21
|
} from "@elcrm/components";
|
|
21
|
-
import type { PricingPlan } from "@elcrm/components";
|
|
22
|
+
import type { PricingPlan, HealthmapData } from "@elcrm/components";
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
## Роли layout
|
|
@@ -34,6 +35,8 @@ import type { PricingPlan } from "@elcrm/components";
|
|
|
34
35
|
- **Stack** — ряд/колонка/сетка (`direction="row"|"column"|"grid"`). Не `Block` / `Row` / `Column`. Сетка: `auto-fit` + `--stack-grid-min`, либо `columns={4}` / `columns={["1fr", "100px", "auto"]}`. С `columns` ряды `stretch` (карточки не наезжают); в одну линию — `style={{ alignItems: "center" }}`. Не `height: 100%` у детей сетки.
|
|
35
36
|
- **Card** — курсор: `--card-pointer` (`auto` / `pointer`). Сетка Stack: `--stack-grid-min`.
|
|
36
37
|
- **Stat** — метрика: `label` + `value`, `tone` как у Badge, `labelMuted`. Не собирать из `span` в приложении.
|
|
38
|
+
- **Healthmap** — теплокарта дней (`data.days`). Данные грузит приложение; слоты `brandName` / `brandSub` / `unit` / `hint`. Токены `--healthmap-cell`, `--healthmap-gap`, `--healthmap-l0`…`l4`.
|
|
39
|
+
- **PanelInfo** — выезд справа (шапка / body / footer). На узком экране — оверлей. Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-width`, `--panel-info-width-min`, `--panel-info-overlay-max`, `--panel-info-backdrop`.
|
|
37
40
|
- **ActionGroup** — тулбар: `label` + `items` (узлы) и/или `children`. Кнопки — `@elcrm/button`, не нативный `<button>`.
|
|
38
41
|
- **TextGroup** — две строки: `title` + `description` (без аватара; с аватаром — AvatarName).
|
|
39
42
|
- **Item** — строка списка. `as` button/a/li, `active`. `variant="card"` — рамка и muted-фон (идеи / тикеты / inbox). Не дублировать `.desk-row` в приложении.
|
|
@@ -42,8 +45,8 @@ import type { PricingPlan } from "@elcrm/components";
|
|
|
42
45
|
## Меню
|
|
43
46
|
|
|
44
47
|
- **Menu** — inline-навигация в шапке. `items`: `value` + `label` + `badge` (`0` не показываем, иначе `Badge` size s). `variant="line"` — вкладки с чертой снизу (не ломать pill селекторами страницы).
|
|
45
|
-
- **Dropdown** — выпадающее меню по `items`/`groups` (portal).
|
|
46
|
-
- **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`.
|
|
48
|
+
- **Dropdown** — выпадающее меню по `items`/`groups` (portal). `placement="auto"|"top"|"bottom"` — куда открывать панель (футер Sidebar — `top`).
|
|
49
|
+
- **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`. `placement` как у Dropdown: позиция по **реальной** высоте панели, не по `maxHeight` 420.
|
|
47
50
|
|
|
48
51
|
## Avatar
|
|
49
52
|
|
|
@@ -59,6 +62,9 @@ import type { PricingPlan } from "@elcrm/components";
|
|
|
59
62
|
## Можно
|
|
60
63
|
|
|
61
64
|
- `--layout-height` — высота каркаса `Layout` при `fixed` (по умолчанию `100dvh`).
|
|
65
|
+
- `--layout-padding` / `--layout-margin` / `--layout-max-width` — отступы и макс. ширина корня Layout (`0` / `none` = на весь вьюпорт).
|
|
66
|
+
- `--header-border` — рамка Header (shorthand: все стороны и толщина). Только снизу: `none` + линия через `--header-shadow`.
|
|
67
|
+
- `--header-nav-justify` — выравнивание `nav` в шапке (`flex-start` / `center` / `flex-end`).
|
|
62
68
|
- Узкие токены (`--chat-*`, `--sidebar-*`, `--item-column-*`) по умолчанию = `--shell-*`. Переопределяй только отличие.
|
|
63
69
|
- Задать `--shell-color` / `--shell-padding` / `--shell-radius` один раз — большинство компонентов подхватят.
|
|
64
70
|
- `--popup-shadow` общий с `@elcrm/form`.
|
|
@@ -18,7 +18,8 @@ const { Open, Icon } = Modal.Create(EditModal, "edit-modal");
|
|
|
18
18
|
Open({ id: 123 });
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)
|
|
21
|
+
Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)`.
|
|
22
|
+
Лоадер — тот же `glob` в `Router.createRouter` (не второй glob и не `importModules`). Свой лоадер — `import_modules` у `Init`.
|
|
22
23
|
|
|
23
24
|
## Состав
|
|
24
25
|
|
|
@@ -30,8 +31,26 @@ Open({ id: 123 });
|
|
|
30
31
|
| `Modal.Close(name?)` | одно или все |
|
|
31
32
|
| `Modal.Shake` | тряска |
|
|
32
33
|
|
|
33
|
-
`size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full.
|
|
34
|
-
`escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда.
|
|
34
|
+
`size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full · `p` page (весь экран поверх страницы).
|
|
35
|
+
`escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда. Для `size="p"` обычно `shake={false}`.
|
|
36
|
+
|
|
37
|
+
## Раскрытие из элемента
|
|
38
|
+
|
|
39
|
+
`Create` возвращает `Open`, `Icon` и **`Trigger`**. По клику снимается `getBoundingClientRect`, окно анимируется FLIP из этой позиции (и обратно при закрытии).
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
const Card = Modal.Create(CardView, "card");
|
|
43
|
+
|
|
44
|
+
// Обёртка (предпочтительно)
|
|
45
|
+
<Card.Trigger params={{ id: 1 }}>
|
|
46
|
+
<Item as="li" variant="card">…</Item>
|
|
47
|
+
</Card.Trigger>
|
|
48
|
+
|
|
49
|
+
// Или вручную
|
|
50
|
+
Card.Open({ id: 1 }, { origin: event.currentTarget });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`prefers-reduced-motion: reduce` — без анимации.
|
|
35
54
|
|
|
36
55
|
## Нельзя
|
|
37
56
|
|
|
@@ -13,22 +13,26 @@ Router.createRouter({
|
|
|
13
13
|
glob: import.meta.glob("./{pages,modules}/**/*.{tsx,ts}", { eager: false }),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
<LoadRoute variant="page"
|
|
17
|
-
<span aria-hidden />
|
|
18
|
-
</LoadRoute>
|
|
16
|
+
<LoadRoute variant="page" />
|
|
19
17
|
```
|
|
20
18
|
|
|
19
|
+
`page` → `pages/{Name}.tsx`. `module` → `modules/{Name}/index.tsx`.
|
|
20
|
+
Тот же glob: `Modal.Open("Home", "Demo")` → `modules/Home/modal/Demo.tsx`.
|
|
21
|
+
|
|
21
22
|
## Можно
|
|
22
23
|
|
|
23
24
|
- Состояние: `useRouter`, `setRouter`, `mergeRouter`, `getRouter`, `resetRouter`, `matchesRouter`.
|
|
24
|
-
-
|
|
25
|
+
- Типизация (не обязательна для работы): `declare module "@elcrm/router" { interface RouterDataMap { page: "Auth"|"Main"; module: … } }`.
|
|
25
26
|
- Секции модуля: отдельный ключ (`section` / `listTab` / `accountTab`) — **не один ключ на все вкладки**.
|
|
26
|
-
- `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` (`
|
|
27
|
+
- `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` — только вкладки внутри модуля (`TabSections`), не страницы.
|
|
27
28
|
- Persist sessionStorage (crypto), `urlTemplate` по желанию.
|
|
28
29
|
- `GuardRoute`, `RouteLoader`, `RouterOutletStack`.
|
|
30
|
+
- `paths` / `resolveLazyPage` — если папки не `pages/` и `modules/`.
|
|
29
31
|
|
|
30
32
|
## Нельзя
|
|
31
33
|
|
|
32
34
|
- `react-router` / `window.location` как основной роутер приложения.
|
|
35
|
+
- Второй `import.meta.glob` + `importModules` для модалок, если glob уже в `createRouter`.
|
|
36
|
+
- `resolveLazyPage` и заглушка `<span aria-hidden />` «чтобы завелось».
|
|
33
37
|
- Смешивать ключи вкладок разных модулей (после смены таба откроется чужая lazy-секция).
|
|
34
38
|
- Нормализовать регистр сегментов самостоятельно, если URL-слой включён — библиотека не нижний регистр.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Orbit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Витрина: `web/` (React + Vite) и `api/` (Bun + SQLite), как panel.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
elcrm create my-shop --template orbit
|
|
7
7
|
bun run dev
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
Web: http://localhost:5173
|
|
10
|
+
- Web: http://localhost:5173
|
|
11
|
+
- API: http://localhost:4545 (`/v1/...`)
|
|
11
12
|
|
|
12
|
-
Демо-вход:
|
|
13
|
+
Демо-вход: `admin` / `admin`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orbit-api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "bun run --watch src/index.ts",
|
|
9
|
+
"start": "bun run src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@elcrm/server": "^0.6.2",
|
|
13
|
+
"@elcrm/deploy": "^0.0.10"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Конфиг panel.api */
|
|
2
|
+
|
|
3
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
+
|
|
5
|
+
/** Не 3535 — часто занят icons.api / другими сервисами elCRM */
|
|
6
|
+
export const PORT = Number(process.env.PORT ?? 4545);
|
|
7
|
+
|
|
8
|
+
function requireSecret(envName: string, devFallback: string): string {
|
|
9
|
+
const v = process.env[envName]?.trim();
|
|
10
|
+
if (v) return v;
|
|
11
|
+
if (isProd) {
|
|
12
|
+
console.error(`❌ ${envName} обязателен в production`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
console.warn(`⚠️ ${envName} не задан — dev-секрет (только локально)`);
|
|
16
|
+
return devFallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Секрет AES для ssid (@elcrm/jwt) */
|
|
20
|
+
export const TOKEN = requireSecret("TOKEN", "panel-dev-secret-change-me");
|
|
21
|
+
|
|
22
|
+
/** SQLite по умолчанию */
|
|
23
|
+
export const DB_PATH =
|
|
24
|
+
process.env.SQLITE_PATH?.trim() ||
|
|
25
|
+
process.env.DB_BASE?.trim() ||
|
|
26
|
+
"./data/panel.db";
|
|
27
|
+
|
|
28
|
+
/** Секрет hot-upload (@elcrm/deploy), опционально */
|
|
29
|
+
export const UPD = process.env.UPD?.trim() || "";
|
|
30
|
+
|
|
31
|
+
export function getCorsOrigin(): string | string[] {
|
|
32
|
+
const raw = process.env.CORS_ORIGINS?.trim();
|
|
33
|
+
if (!raw || raw === "*") {
|
|
34
|
+
if (!isProd) {
|
|
35
|
+
return [
|
|
36
|
+
"http://localhost:5173",
|
|
37
|
+
"http://localhost:3000",
|
|
38
|
+
"http://127.0.0.1:5173",
|
|
39
|
+
"http://127.0.0.1:3000",
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
return raw === "*" ? ["http://localhost:5173"] : [];
|
|
43
|
+
}
|
|
44
|
+
const list = raw
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
return list.length === 1 ? list[0]! : list;
|
|
49
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orbit.api — createRouter + createServer; JWT/CORS/cron из конфига.
|
|
3
|
+
*/
|
|
4
|
+
import { createServer, cors as serverCors } from "@elcrm/server";
|
|
5
|
+
import { DB_PATH, getCorsOrigin, PORT, TOKEN } from "./config";
|
|
6
|
+
import { initDatabase } from "./lib/db";
|
|
7
|
+
import { jwt } from "./lib/jwt";
|
|
8
|
+
import "./lib/cron";
|
|
9
|
+
import { registerRoutes } from "./routes/index";
|
|
10
|
+
|
|
11
|
+
async function start() {
|
|
12
|
+
// TOKEN в env — createJWT/createServer видят один секрет
|
|
13
|
+
if (!process.env.TOKEN?.trim()) {
|
|
14
|
+
process.env.TOKEN = TOKEN;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await initDatabase();
|
|
18
|
+
registerRoutes();
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
createServer({
|
|
22
|
+
port: PORT,
|
|
23
|
+
prefix: "/v1",
|
|
24
|
+
// alias + password hash — явный jwt; иначе хватило бы omit + TOKEN
|
|
25
|
+
jwt,
|
|
26
|
+
// auth в exclude: pass без ssid; me/profile — middleware requireUser (user уже из createServer)
|
|
27
|
+
jwtAuth: { bearer: false },
|
|
28
|
+
securityHeaders: true,
|
|
29
|
+
cron: true,
|
|
30
|
+
cors: serverCors({
|
|
31
|
+
origin: getCorsOrigin(),
|
|
32
|
+
credentials: true,
|
|
33
|
+
headers: [
|
|
34
|
+
"Content-Type",
|
|
35
|
+
"Authorization",
|
|
36
|
+
"ssid",
|
|
37
|
+
"user-agent",
|
|
38
|
+
"X-Request-Id",
|
|
39
|
+
],
|
|
40
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
41
|
+
}),
|
|
42
|
+
onError: (err, ctx) => {
|
|
43
|
+
console.error(
|
|
44
|
+
"[orbit.api]",
|
|
45
|
+
ctx.requestId,
|
|
46
|
+
ctx.path.join("/"),
|
|
47
|
+
err instanceof Error ? err.message : err,
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
} catch (e) {
|
|
52
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
53
|
+
if (msg.includes("EADDRINUSE") || msg.includes("port")) {
|
|
54
|
+
console.error(
|
|
55
|
+
`❌ Порт ${PORT} занят. Освободите его или задайте другой:\n` +
|
|
56
|
+
` PORT=4546 bun run dev\n` +
|
|
57
|
+
` lsof -iTCP:${PORT} -sTCP:LISTEN`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`orbit.api: http://localhost:${PORT}`);
|
|
64
|
+
console.log(` path-RPC: /v1/{router}/{method}`);
|
|
65
|
+
console.log(` db: ${DB_PATH}`);
|
|
66
|
+
console.log(` demo: admin / admin`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
start().catch((e) => {
|
|
70
|
+
console.error(e instanceof Error ? e.message : e);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Минимальный cron — heartbeat. createServer({ cron: true }) / CRON_AUTORUN=1
|
|
3
|
+
* подхватит cron.run() сам.
|
|
4
|
+
*/
|
|
5
|
+
import { cron } from "@elcrm/server/cron";
|
|
6
|
+
|
|
7
|
+
cron.use(
|
|
8
|
+
() => {
|
|
9
|
+
console.log(`[cron] heartbeat ${new Date().toISOString()}`);
|
|
10
|
+
},
|
|
11
|
+
"0 */10 * * * *",
|
|
12
|
+
"heartbeat",
|
|
13
|
+
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite через @elcrm/server/db + схема users и демо-админ.
|
|
3
|
+
* Каталог для файла БД создаёт сам openSqlite (ensureDir) — node:fs не нужен.
|
|
4
|
+
*/
|
|
5
|
+
import { db, initDB } from "@elcrm/server/db";
|
|
6
|
+
import { DB_PATH } from "../config";
|
|
7
|
+
import { jwt } from "./jwt";
|
|
8
|
+
|
|
9
|
+
let ready: Promise<typeof db> | null = null;
|
|
10
|
+
|
|
11
|
+
export async function initDatabase() {
|
|
12
|
+
if (!ready) {
|
|
13
|
+
ready = (async () => {
|
|
14
|
+
await initDB({ type: "sqlite", database: DB_PATH });
|
|
15
|
+
const ok = await db.ping();
|
|
16
|
+
if (!ok) throw new Error(`SQLite недоступна: ${DB_PATH}`);
|
|
17
|
+
await migrate();
|
|
18
|
+
console.log(`✅ DB (sqlite): ${DB_PATH}`);
|
|
19
|
+
return db;
|
|
20
|
+
})();
|
|
21
|
+
}
|
|
22
|
+
return ready;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function migrate() {
|
|
26
|
+
await db.run(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
login TEXT NOT NULL UNIQUE,
|
|
30
|
+
password TEXT NOT NULL,
|
|
31
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
32
|
+
admin INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
display_name TEXT DEFAULT '',
|
|
34
|
+
email TEXT DEFAULT '',
|
|
35
|
+
avatar TEXT DEFAULT ''
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
const count = await db.get<{ n: number }>(
|
|
40
|
+
"SELECT count(*) AS n FROM users",
|
|
41
|
+
);
|
|
42
|
+
if (!count?.n) {
|
|
43
|
+
const hash = jwt.password("admin");
|
|
44
|
+
await db.run(
|
|
45
|
+
`INSERT INTO users (login, password, active, admin, display_name, email)
|
|
46
|
+
VALUES (?, ?, 1, 1, ?, ?)`,
|
|
47
|
+
["admin", hash, "Administrator", "admin@localhost"],
|
|
48
|
+
);
|
|
49
|
+
console.log("✅ seed: admin / admin");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { db };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createJWT } from "@elcrm/server/jwt";
|
|
2
|
+
import { TOKEN } from "../config";
|
|
3
|
+
|
|
4
|
+
export const jwt = createJWT({
|
|
5
|
+
secret: TOKEN,
|
|
6
|
+
alias: [
|
|
7
|
+
["i", "id"],
|
|
8
|
+
["a", "admin"],
|
|
9
|
+
],
|
|
10
|
+
validateAgent: true,
|
|
11
|
+
excludeRoutes: ["auth", "health", "version"],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type JwtUser = {
|
|
15
|
+
id: number;
|
|
16
|
+
admin?: number;
|
|
17
|
+
agent?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|