elcrm 1.1.16 → 1.1.17
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.js +2 -2
- package/package.json +1 -1
- package/templates/elcrm-cursor/rules/elcrm-ui.mdc +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 +9 -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 +20 -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
|
}
|
|
@@ -46,8 +46,8 @@ function createCssScoped(options) {
|
|
|
46
46
|
if (!letter) {
|
|
47
47
|
throw new Error(`[css-scoped] нет буквы для компонента "${component}". Добавьте в components.`);
|
|
48
48
|
}
|
|
49
|
-
if (!/^[a-z0-
|
|
50
|
-
throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-
|
|
49
|
+
if (!/^[a-z0-9A-Z]$/.test(letter)) {
|
|
50
|
+
throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9A-Z: "${letter}"`);
|
|
51
51
|
}
|
|
52
52
|
const scoped = `${prefix}${letter}${name}`;
|
|
53
53
|
const owner = `${component}.${name}`;
|
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`, разные ключи вкладок.
|
|
@@ -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-l0`…`l4`.
|
|
39
|
+
- **PanelInfo** — выезд справа (шапка / body / footer). На узком экране — оверлей. Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-*`.
|
|
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
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRouter,
|
|
3
|
+
rpcErr,
|
|
4
|
+
rpcLogout,
|
|
5
|
+
rpcOk,
|
|
6
|
+
type Middleware,
|
|
7
|
+
} from "@elcrm/server";
|
|
8
|
+
import { db } from "../lib/db";
|
|
9
|
+
import { jwt } from "../lib/jwt";
|
|
10
|
+
|
|
11
|
+
type UserRow = {
|
|
12
|
+
id: number;
|
|
13
|
+
login: string;
|
|
14
|
+
password: string;
|
|
15
|
+
active: number | null;
|
|
16
|
+
admin: number | null;
|
|
17
|
+
display_name: string | null;
|
|
18
|
+
email: string | null;
|
|
19
|
+
avatar: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const USER_PUBLIC_COLS =
|
|
23
|
+
"id, login, active, admin, display_name, email, avatar";
|
|
24
|
+
|
|
25
|
+
function publicUser(
|
|
26
|
+
row: Pick<
|
|
27
|
+
UserRow,
|
|
28
|
+
"id" | "login" | "admin" | "display_name" | "email" | "avatar"
|
|
29
|
+
>,
|
|
30
|
+
) {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
login: row.login,
|
|
34
|
+
admin: Number(row.admin) === 1 ? 1 : 0,
|
|
35
|
+
display_name: String(row.display_name ?? ""),
|
|
36
|
+
email: String(row.email ?? ""),
|
|
37
|
+
avatar: String(row.avatar ?? ""),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* createServer уже разобрал ssid → params.user.
|
|
43
|
+
* Не вызываем jwt.init повторно — только наличие user.
|
|
44
|
+
* publicMethods: pass (логин без сессии).
|
|
45
|
+
*/
|
|
46
|
+
const requireUser: Middleware = async ({ path, user }) => {
|
|
47
|
+
if (path[1] === "pass") return;
|
|
48
|
+
if (!user?.id) return rpcLogout();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function registerAuthRoutes() {
|
|
52
|
+
createRouter("auth")
|
|
53
|
+
.use(requireUser)
|
|
54
|
+
.use("pass", async ({ body, headers, jwt: jwtCtx }) => {
|
|
55
|
+
const login = String(body?.login ?? "").trim();
|
|
56
|
+
const password = String(body?.password ?? "");
|
|
57
|
+
|
|
58
|
+
if (!login || !password) {
|
|
59
|
+
return rpcErr("Неверные данные", 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const data = await db.get<UserRow>(
|
|
63
|
+
"SELECT * FROM users WHERE login = ?",
|
|
64
|
+
[login],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!data) return rpcErr("Пользователь не найден");
|
|
68
|
+
if (data.active === 0) {
|
|
69
|
+
return rpcErr("Аккаунт заблокирован", 403);
|
|
70
|
+
}
|
|
71
|
+
if (!data.password || data.password !== jwt.password(password)) {
|
|
72
|
+
return rpcErr("Данные не верны");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pub = publicUser(data);
|
|
76
|
+
const tokenApi = jwtCtx ?? jwt;
|
|
77
|
+
const ssid = await tokenApi.create(
|
|
78
|
+
{ id: pub.id, admin: pub.admin },
|
|
79
|
+
{ headers },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return rpcOk(pub, { ssid });
|
|
83
|
+
})
|
|
84
|
+
.use("me", async ({ user }) => {
|
|
85
|
+
const row = await db.get<UserRow>(
|
|
86
|
+
`SELECT ${USER_PUBLIC_COLS} FROM users WHERE id = ?`,
|
|
87
|
+
[user.id],
|
|
88
|
+
);
|
|
89
|
+
if (!row || row.active === 0) return rpcLogout();
|
|
90
|
+
return rpcOk(publicUser(row));
|
|
91
|
+
})
|
|
92
|
+
.use("profile", async ({ user, body }) => {
|
|
93
|
+
const display_name = String(
|
|
94
|
+
body?.display_name ?? body?.name ?? "",
|
|
95
|
+
).trim();
|
|
96
|
+
const email = String(body?.email ?? "").trim();
|
|
97
|
+
|
|
98
|
+
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
99
|
+
return rpcErr("Некорректный email", 400);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await db.run(
|
|
103
|
+
`UPDATE users SET display_name = ?, email = ? WHERE id = ?`,
|
|
104
|
+
[display_name, email, user.id],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const row = await db.get<UserRow>(
|
|
108
|
+
`SELECT ${USER_PUBLIC_COLS} FROM users WHERE id = ?`,
|
|
109
|
+
[user.id],
|
|
110
|
+
);
|
|
111
|
+
if (!row) return rpcErr("Пользователь не найден", 404);
|
|
112
|
+
return rpcOk(publicUser(row));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Минимальная точка для @elcrm/deploy (опциональный upload).
|
|
3
|
+
* Полный деплой — через CLI `elcrm build`; здесь — health зависимости.
|
|
4
|
+
*/
|
|
5
|
+
import { createRouter, rpcOk } from "@elcrm/server";
|
|
6
|
+
import { UPD } from "../config";
|
|
7
|
+
|
|
8
|
+
export function registerDeployRoutes() {
|
|
9
|
+
createRouter("deploy").use("status", async () =>
|
|
10
|
+
rpcOk({
|
|
11
|
+
ok: true,
|
|
12
|
+
uploadEnabled: Boolean(UPD),
|
|
13
|
+
package: "@elcrm/deploy",
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRouter, rpcOk } from "@elcrm/server";
|
|
2
|
+
import { registerAuthRoutes } from "./auth";
|
|
3
|
+
import { registerDeployRoutes } from "./deploy";
|
|
4
|
+
|
|
5
|
+
export function registerRoutes() {
|
|
6
|
+
createRouter("health").use("ping", async () =>
|
|
7
|
+
rpcOk({ ok: true, service: "panel.api" }),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
createRouter("version").use("get", async () =>
|
|
11
|
+
rpcOk(process.env.APP_VERSION ?? "0.0.0"),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
registerAuthRoutes();
|
|
15
|
+
registerDeployRoutes();
|
|
16
|
+
}
|
|
@@ -3,37 +3,15 @@
|
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
+
"workspaces": ["web", "api"],
|
|
6
7
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
8
|
-
"
|
|
9
|
-
"
|
|
8
|
+
"dev": "bun run scripts/dev.ts",
|
|
9
|
+
"dev:web": "bun --cwd web run dev",
|
|
10
|
+
"dev:api": "bun --cwd api run dev",
|
|
11
|
+
"build": "bun --cwd web run build",
|
|
12
|
+
"start": "bun --cwd api run start"
|
|
10
13
|
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"@elcrm/api": "0.1.55",
|
|
14
|
-
"@elcrm/button": "1.1.0",
|
|
15
|
-
"@elcrm/components": "0.1.2",
|
|
16
|
-
"@elcrm/core": "0.1.3",
|
|
17
|
-
"@elcrm/crypto": "0.0.2",
|
|
18
|
-
"@elcrm/error-boundary": "0.0.2",
|
|
19
|
-
"@elcrm/form": "0.1.8",
|
|
20
|
-
"@elcrm/modal": "0.0.34",
|
|
21
|
-
"@elcrm/notice": "0.1.3",
|
|
22
|
-
"@elcrm/notifications": "0.3.1",
|
|
23
|
-
"@elcrm/passkey": "0.2.3",
|
|
24
|
-
"@elcrm/router": "0.0.58",
|
|
25
|
-
"@elcrm/search": "0.1.5",
|
|
26
|
-
"@elcrm/setting": "0.0.6",
|
|
27
|
-
"@elcrm/state": "0.0.7",
|
|
28
|
-
"@elcrm/tooltip": "0.1.0",
|
|
29
|
-
"react": "^18.2.0",
|
|
30
|
-
"react-dom": "^18.2.0"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"@types/react": "^18.2.79",
|
|
34
|
-
"@types/react-dom": "^18.2.25",
|
|
35
|
-
"@vitejs/plugin-react": "^4.3.1",
|
|
36
|
-
"typescript": "^5.8.2",
|
|
37
|
-
"vite": "^5.4.2"
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
38
16
|
}
|
|
39
17
|
}
|