work-license-server 1.0.20 → 1.0.22
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/assets/ODANT-WORK-presentation.html +1683 -0
- package/assets/ODANT-WORK-presentation.pdf +0 -0
- package/assets/admin.js +63 -0
- package/assets/landing.css +83 -0
- package/assets/landing.js +88 -1
- package/bin/server.mjs +63 -4
- package/package.json +1 -1
- package/src/admin.js +9 -0
- package/src/index.js +7 -1
- package/src/landing.js +68 -5
- package/src/server.js +44 -5
- package/src/store.js +766 -21
|
Binary file
|
package/assets/admin.js
CHANGED
|
@@ -311,6 +311,9 @@
|
|
|
311
311
|
if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
|
|
312
312
|
if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и администраторы (${users.length})`;
|
|
313
313
|
if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
|
|
314
|
+
if ($('#admDataDirLabel') && data.dataDir) {
|
|
315
|
+
$('#admDataDirLabel').textContent = `Папка БД: ${data.dataDir}`;
|
|
316
|
+
}
|
|
314
317
|
|
|
315
318
|
renderServersTable(licenses);
|
|
316
319
|
renderFranchisesTable(franchises);
|
|
@@ -870,6 +873,57 @@
|
|
|
870
873
|
URL.revokeObjectURL(url);
|
|
871
874
|
}
|
|
872
875
|
|
|
876
|
+
async function exportAdminBackup() {
|
|
877
|
+
try {
|
|
878
|
+
const bundle = await api('/api/admin/backup/export');
|
|
879
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
880
|
+
const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
|
|
881
|
+
const url = URL.createObjectURL(blob);
|
|
882
|
+
const a = document.createElement('a');
|
|
883
|
+
const dayStr = new Date().toISOString().slice(0, 10);
|
|
884
|
+
a.href = url;
|
|
885
|
+
a.download = `odant-work-license-backup-${dayStr}.json`;
|
|
886
|
+
document.body.appendChild(a);
|
|
887
|
+
a.click();
|
|
888
|
+
a.remove();
|
|
889
|
+
URL.revokeObjectURL(url);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
alert(err.message);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function importAdminBackupFile(file) {
|
|
897
|
+
if (!file) return;
|
|
898
|
+
try {
|
|
899
|
+
const text = await file.text();
|
|
900
|
+
const parsed = JSON.parse(text);
|
|
901
|
+
const res = await api('/api/admin/backup/import', {
|
|
902
|
+
backup: parsed,
|
|
903
|
+
mode: 'merge',
|
|
904
|
+
});
|
|
905
|
+
await refreshRegistry();
|
|
906
|
+
const c = res.counts || {};
|
|
907
|
+
alert(`Резервная копия успешно объединена с базой!\nПользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
alert('Ошибка импорта резервной копии: ' + (err.message || err));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function recoverAdminFromNpx() {
|
|
915
|
+
try {
|
|
916
|
+
const res = await api('/api/admin/backup/recover', {});
|
|
917
|
+
await refreshRegistry();
|
|
918
|
+
const c = res.counts || {};
|
|
919
|
+
const srcCount = Array.isArray(res.mergedSources) ? res.mergedSources.length : 0;
|
|
920
|
+
alert(`Поиск по кэшу npx и системным папкам завершён (найдено источников: ${srcCount}).\nВ базе сейчас — пользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
alert(err.message);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
873
927
|
/* ================= Привязка событий ================= */
|
|
874
928
|
function bind() {
|
|
875
929
|
document.addEventListener('click', (e) => {
|
|
@@ -944,9 +998,18 @@
|
|
|
944
998
|
navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
|
|
945
999
|
break;
|
|
946
1000
|
case 'cert-download': downloadCurrentCert(); break;
|
|
1001
|
+
case 'admin-export-backup': exportAdminBackup(); break;
|
|
1002
|
+
case 'admin-import-backup': $('#inpAdmBackupFile')?.click(); break;
|
|
1003
|
+
case 'admin-recover-npx': recoverAdminFromNpx(); break;
|
|
947
1004
|
}
|
|
948
1005
|
});
|
|
949
1006
|
|
|
1007
|
+
$('#inpAdmBackupFile')?.addEventListener('change', (e) => {
|
|
1008
|
+
const file = e.target.files?.[0];
|
|
1009
|
+
if (file) importAdminBackupFile(file);
|
|
1010
|
+
e.target.value = '';
|
|
1011
|
+
});
|
|
1012
|
+
|
|
950
1013
|
$('#btnTheme')?.addEventListener('click', toggleTheme);
|
|
951
1014
|
$('#adminLoginScreen')?.addEventListener('keydown', (e) => {
|
|
952
1015
|
if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
|
package/assets/landing.css
CHANGED
|
@@ -843,6 +843,89 @@ html[data-theme="dark"] .sec-nav-item.active {
|
|
|
843
843
|
.modal-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
|
844
844
|
.modal-head img { width: 46px; height: 46px; }
|
|
845
845
|
|
|
846
|
+
/* ---------- Интерактивный плеер презентации ---------- */
|
|
847
|
+
.pres-modal { padding: 14px; background: rgba(2, 5, 12, .82); }
|
|
848
|
+
.pres-modal .pres-card {
|
|
849
|
+
max-width: 1320px;
|
|
850
|
+
width: min(1320px, 96vw);
|
|
851
|
+
max-height: 94vh;
|
|
852
|
+
padding: 14px 18px;
|
|
853
|
+
display: flex;
|
|
854
|
+
flex-direction: column;
|
|
855
|
+
gap: 12px;
|
|
856
|
+
overflow: hidden;
|
|
857
|
+
}
|
|
858
|
+
.pres-top {
|
|
859
|
+
display: flex;
|
|
860
|
+
align-items: center;
|
|
861
|
+
justify-content: space-between;
|
|
862
|
+
gap: 12px;
|
|
863
|
+
flex-wrap: wrap;
|
|
864
|
+
}
|
|
865
|
+
.pres-title-wrap {
|
|
866
|
+
display: flex;
|
|
867
|
+
align-items: center;
|
|
868
|
+
gap: 10px;
|
|
869
|
+
}
|
|
870
|
+
.pres-title-wrap img { width: 34px; height: 34px; }
|
|
871
|
+
.pres-title-wrap b { display: block; font-size: 15px; line-height: 1.2; }
|
|
872
|
+
.pres-title-wrap span { display: block; font-size: 12px; }
|
|
873
|
+
.pres-sections { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
874
|
+
.pres-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
875
|
+
.pres-stage {
|
|
876
|
+
width: 100%;
|
|
877
|
+
aspect-ratio: 297 / 167;
|
|
878
|
+
max-height: calc(94vh - 140px);
|
|
879
|
+
border-radius: 12px;
|
|
880
|
+
overflow: hidden;
|
|
881
|
+
border: 1px solid var(--line-strong);
|
|
882
|
+
background: #060d18;
|
|
883
|
+
position: relative;
|
|
884
|
+
}
|
|
885
|
+
.pres-stage iframe {
|
|
886
|
+
width: 100%;
|
|
887
|
+
height: 100%;
|
|
888
|
+
border: 0;
|
|
889
|
+
display: block;
|
|
890
|
+
}
|
|
891
|
+
.pres-bottom {
|
|
892
|
+
display: flex;
|
|
893
|
+
align-items: center;
|
|
894
|
+
justify-content: space-between;
|
|
895
|
+
gap: 12px;
|
|
896
|
+
flex-wrap: wrap;
|
|
897
|
+
}
|
|
898
|
+
.pres-counter {
|
|
899
|
+
font-size: 13px;
|
|
900
|
+
font-weight: 700;
|
|
901
|
+
min-width: 92px;
|
|
902
|
+
text-align: center;
|
|
903
|
+
}
|
|
904
|
+
.pres-dots {
|
|
905
|
+
display: flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
gap: 5px;
|
|
908
|
+
flex-wrap: wrap;
|
|
909
|
+
}
|
|
910
|
+
.pres-dot {
|
|
911
|
+
width: 28px;
|
|
912
|
+
height: 28px;
|
|
913
|
+
border-radius: 8px;
|
|
914
|
+
border: 1px solid var(--line-strong);
|
|
915
|
+
background: color-mix(in srgb, var(--panel-solid) 60%, transparent);
|
|
916
|
+
color: var(--text-2);
|
|
917
|
+
font-size: 12px;
|
|
918
|
+
font-weight: 700;
|
|
919
|
+
cursor: pointer;
|
|
920
|
+
transition: all .15s;
|
|
921
|
+
}
|
|
922
|
+
.pres-dot:hover { border-color: var(--brand); color: var(--text); }
|
|
923
|
+
.pres-dot.on {
|
|
924
|
+
background: linear-gradient(135deg, var(--brand), #2563eb);
|
|
925
|
+
border-color: var(--brand);
|
|
926
|
+
color: #fff;
|
|
927
|
+
}
|
|
928
|
+
|
|
846
929
|
/* ---------- Подвал ---------- */
|
|
847
930
|
.footer { margin-top: 70px; padding-top: 34px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 30px; font-size: 13px; color: var(--muted); }
|
|
848
931
|
.footer h4 { margin: 0 0 10px; color: var(--text); font-size: 14px; }
|
package/assets/landing.js
CHANGED
|
@@ -2100,6 +2100,68 @@
|
|
|
2100
2100
|
else requestAnimationFrame(frame);
|
|
2101
2101
|
}
|
|
2102
2102
|
|
|
2103
|
+
/* ================= Интерактивный плеер презентации ODANT-WORK ================= */
|
|
2104
|
+
let currentPresSlide = 1;
|
|
2105
|
+
const PRES_TOTAL_SLIDES = 12;
|
|
2106
|
+
|
|
2107
|
+
function syncPresIndicators(slideNum) {
|
|
2108
|
+
currentPresSlide = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(slideNum) || 1));
|
|
2109
|
+
const cnt = $('#presCounter');
|
|
2110
|
+
if (cnt) cnt.textContent = `Слайд ${currentPresSlide} / ${PRES_TOTAL_SLIDES}`;
|
|
2111
|
+
$$('#presDots .pres-dot').forEach(btn => {
|
|
2112
|
+
btn.classList.toggle('on', Number(btn.dataset.slide) === currentPresSlide);
|
|
2113
|
+
});
|
|
2114
|
+
const sec = currentPresSlide >= 10 ? 'franchise' : (currentPresSlide >= 8 ? 'clients' : 'product');
|
|
2115
|
+
$$('#presSectionTabs .chip').forEach(btn => {
|
|
2116
|
+
btn.classList.toggle('on', btn.dataset.sec === sec);
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function openPresentationModal(startSlide = 1) {
|
|
2121
|
+
const modal = $('#presentationModal');
|
|
2122
|
+
const frame = $('#presFrame');
|
|
2123
|
+
if (!modal || !frame) return;
|
|
2124
|
+
const n = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(startSlide) || 1));
|
|
2125
|
+
syncPresIndicators(n);
|
|
2126
|
+
const targetSrc = `/presentation?embed=1&slide=${n}`;
|
|
2127
|
+
if (!frame.getAttribute('src')) {
|
|
2128
|
+
frame.setAttribute('src', targetSrc);
|
|
2129
|
+
} else {
|
|
2130
|
+
try {
|
|
2131
|
+
frame.contentWindow?.postMessage({ type: 'odant-presentation-goto', slide: n }, '*');
|
|
2132
|
+
} catch {
|
|
2133
|
+
frame.setAttribute('src', targetSrc);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
modal.classList.add('open');
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function closePresentationModal() {
|
|
2140
|
+
$('#presentationModal')?.classList.remove('open');
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function gotoPresentationSlide(slideNum) {
|
|
2144
|
+
const n = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(slideNum) || 1));
|
|
2145
|
+
syncPresIndicators(n);
|
|
2146
|
+
const frame = $('#presFrame');
|
|
2147
|
+
try {
|
|
2148
|
+
frame?.contentWindow?.postMessage({ type: 'odant-presentation-goto', slide: n }, '*');
|
|
2149
|
+
} catch { /* ignore */ }
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function stepPresentationSlide(delta) {
|
|
2153
|
+
gotoPresentationSlide(currentPresSlide + (Number(delta) || 0));
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
window.addEventListener('message', (e) => {
|
|
2157
|
+
const d = e.data || {};
|
|
2158
|
+
if (d.type === 'odant-presentation-slide' && d.slide) {
|
|
2159
|
+
syncPresIndicators(d.slide);
|
|
2160
|
+
} else if (d.type === 'odant-presentation-close') {
|
|
2161
|
+
closePresentationModal();
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2103
2165
|
/* ================= Привязка событий ================= */
|
|
2104
2166
|
function bind() {
|
|
2105
2167
|
document.addEventListener('click', (e) => {
|
|
@@ -2223,6 +2285,26 @@
|
|
|
2223
2285
|
case 'cert-copy':
|
|
2224
2286
|
navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
|
|
2225
2287
|
break;
|
|
2288
|
+
case 'open-presentation':
|
|
2289
|
+
e.preventDefault();
|
|
2290
|
+
openPresentationModal(Number(el.dataset.slide || 1));
|
|
2291
|
+
break;
|
|
2292
|
+
case 'pres-close':
|
|
2293
|
+
closePresentationModal();
|
|
2294
|
+
break;
|
|
2295
|
+
case 'pres-goto':
|
|
2296
|
+
gotoPresentationSlide(Number(el.dataset.slide || 1));
|
|
2297
|
+
break;
|
|
2298
|
+
case 'pres-prev':
|
|
2299
|
+
stepPresentationSlide(-1);
|
|
2300
|
+
break;
|
|
2301
|
+
case 'pres-next':
|
|
2302
|
+
stepPresentationSlide(1);
|
|
2303
|
+
break;
|
|
2304
|
+
case 'pres-jump-stage':
|
|
2305
|
+
closePresentationModal();
|
|
2306
|
+
if (el.dataset.targetStage) switchStage(el.dataset.targetStage);
|
|
2307
|
+
break;
|
|
2226
2308
|
case 'ai-toggle': toggleAi(); break;
|
|
2227
2309
|
case 'ai-send': sendAi(); break;
|
|
2228
2310
|
}
|
|
@@ -2230,8 +2312,13 @@
|
|
|
2230
2312
|
$('#authModal')?.addEventListener('click', (e) => { if (e.target.id === 'authModal') closeAuth(); });
|
|
2231
2313
|
$('#ofertaModal')?.addEventListener('click', (e) => { if (e.target.id === 'ofertaModal') closeOfertaModal(); });
|
|
2232
2314
|
$('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
|
|
2315
|
+
$('#presentationModal')?.addEventListener('click', (e) => { if (e.target.id === 'presentationModal') closePresentationModal(); });
|
|
2233
2316
|
document.addEventListener('keydown', (e) => {
|
|
2234
|
-
if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
|
|
2317
|
+
if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); closePresentationModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
|
|
2318
|
+
else if ($('#presentationModal')?.classList.contains('open')) {
|
|
2319
|
+
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); stepPresentationSlide(1); }
|
|
2320
|
+
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); stepPresentationSlide(-1); }
|
|
2321
|
+
}
|
|
2235
2322
|
});
|
|
2236
2323
|
$('#authModal')?.addEventListener('keydown', (e) => {
|
|
2237
2324
|
if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
|
package/bin/server.mjs
CHANGED
|
@@ -2,16 +2,75 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Точка запуска лицензионного сервера и лендинга ПАК WORK (work.odant.org).
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import * as childProcess from 'node:child_process';
|
|
6
9
|
import { createLicenseServerApp } from '../src/server.js';
|
|
10
|
+
import { resolvePersistentDataDir } from '../src/store.js';
|
|
7
11
|
|
|
8
12
|
const port = Number(process.env.PORT || process.env.WORK_LICENSE_PORT || 8020);
|
|
9
|
-
const
|
|
10
|
-
const dataDir = process.env.WORK_LICENSE_DATA_DIR || defaultDataDir;
|
|
13
|
+
const dataDir = resolvePersistentDataDir();
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
async function releasePreviousInstance() {
|
|
16
|
+
const pidFile = path.join(dataDir, 'run.pid');
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(pidFile)) {
|
|
19
|
+
const oldPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
|
|
20
|
+
if (Number.isInteger(oldPid) && oldPid > 0 && oldPid !== process.pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(oldPid);
|
|
23
|
+
for (let i = 0; i < 15; i++) {
|
|
24
|
+
await new Promise(r => setTimeout(r, 100));
|
|
25
|
+
try { process.kill(oldPid, 0); }
|
|
26
|
+
catch { break; }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* процесс уже завершён */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* ignore */ }
|
|
34
|
+
|
|
35
|
+
const isPortBusy = () => new Promise((resolve) => {
|
|
36
|
+
const sock = net.createConnection({ host: '127.0.0.1', port });
|
|
37
|
+
sock.once('connect', () => { sock.destroy(); resolve(true); });
|
|
38
|
+
sock.once('error', () => { sock.destroy(); resolve(false); });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (await isPortBusy() && process.platform === 'win32') {
|
|
42
|
+
try {
|
|
43
|
+
const out = childProcess.execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
44
|
+
for (const line of out.split(/\r?\n/)) {
|
|
45
|
+
const m = line.trim().match(/LISTENING\s+(\d+)$/i);
|
|
46
|
+
const pid = m ? Number(m[1]) : 0;
|
|
47
|
+
if (pid && pid !== process.pid) {
|
|
48
|
+
try { process.kill(pid); } catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < 15; i++) {
|
|
52
|
+
await new Promise(r => setTimeout(r, 100));
|
|
53
|
+
if (!(await isPortBusy()))
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(pidFile, String(process.pid), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await releasePreviousInstance();
|
|
68
|
+
|
|
69
|
+
const app = createLicenseServerApp({ dataDir, autoRecover: true });
|
|
13
70
|
const server = await app.listen(port);
|
|
14
71
|
const addr = server.address();
|
|
15
72
|
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
73
|
+
const counts = app.store.getStoreCounts();
|
|
16
74
|
console.log(`[work.odant.org] Витрина ODANT-WORK: http://localhost:${actualPort}/`);
|
|
17
75
|
console.log(`[work.odant.org] Панель администратора: http://localhost:${actualPort}/admin`);
|
|
76
|
+
console.log(`[work.odant.org] Постоянная база данных: ${app.store.stateFile} (пользователей: ${counts.users}, серверов/лицензий: ${counts.licenses}, заказов: ${counts.orders})`);
|
package/package.json
CHANGED
package/src/admin.js
CHANGED
|
@@ -129,6 +129,15 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
129
129
|
<button type="button" class="sec-nav-item" data-adm-tab="orders" id="navAdmOrders">Заказы и счета (0)</button>
|
|
130
130
|
<button type="button" class="sec-nav-item" data-adm-tab="users" id="navAdmUsers">Клиенты и администраторы (0)</button>
|
|
131
131
|
<button type="button" class="sec-nav-item" data-adm-tab="tickets" id="navAdmTickets">Тикеты поддержки (0)</button>
|
|
132
|
+
<div class="sec-nav-sep"></div>
|
|
133
|
+
<div style="padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;gap:6px">
|
|
134
|
+
<div class="small muted" style="font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em">Резервная копия базы</div>
|
|
135
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-export-backup">📥 Скачать бэкап (.json)</button>
|
|
136
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-import-backup">📤 Восстановить из файла</button>
|
|
137
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-recover-npx" title="Найти и объединить данные из предыдущих запусков npx">🔄 Найти данные из npx</button>
|
|
138
|
+
<input type="file" id="inpAdmBackupFile" accept=".json,application/json" style="display:none">
|
|
139
|
+
<div class="small muted" id="admDataDirLabel" style="font-size:11px;word-break:break-all;line-height:1.3;margin-top:2px"></div>
|
|
140
|
+
</div>
|
|
132
141
|
</aside>
|
|
133
142
|
|
|
134
143
|
<!-- Правая область выбранного раздела -->
|
package/src/index.js
CHANGED
|
@@ -37,7 +37,13 @@ export {
|
|
|
37
37
|
parseOwnerAccessToken,
|
|
38
38
|
verifyOwnerAccessToken,
|
|
39
39
|
} from './crypto.js';
|
|
40
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
LicenseStore,
|
|
42
|
+
isEphemeralPackagePath,
|
|
43
|
+
resolvePersistentDataDir,
|
|
44
|
+
findCandidateRecoveryDirs,
|
|
45
|
+
mergeStoreStates,
|
|
46
|
+
} from './store.js';
|
|
41
47
|
export { YooKassaService } from './yookassa.js';
|
|
42
48
|
export { SupportBotsHub } from './bots.js';
|
|
43
49
|
export { LicenseServerAiAgent } from './agent.js';
|
package/src/landing.js
CHANGED
|
@@ -200,7 +200,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
200
200
|
<span class="kicker">Платформа цифровой организации</span>
|
|
201
201
|
<h1 class="h2" style="font-size:clamp(1.9rem,3vw,2.6rem);margin:4px 0 0">ODANT-WORK — учёт, документооборот, процессы и <span class="grad">приватный ИИ</span></h1>
|
|
202
202
|
</div>
|
|
203
|
-
<
|
|
203
|
+
<div>
|
|
204
|
+
<p style="margin:0 0 12px">Все данные предприятия — справочники, проводки, документы, переписка и задачи — живут в единой модели с общими правами доступа и работают автономно в вашем контуре без передачи данных наружу.</p>
|
|
205
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
206
|
+
<button type="button" class="btn btn-primary btn-sm" data-action="open-presentation" data-slide="1">${icon('docs')} Смотреть презентацию (12 слайдов)</button>
|
|
207
|
+
<a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
204
210
|
</div>
|
|
205
211
|
|
|
206
212
|
<div class="bento">
|
|
@@ -262,9 +268,10 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
262
268
|
<h2 class="h2">ODANT-WORK · ${esc(P.sku)}</h2>
|
|
263
269
|
<div class="price-big">${rub(P.basePriceRub)}</div>
|
|
264
270
|
<p class="note">Вечная лицензия на ${P.baseSeats} одновременных рабочих сессий и ${P.includedSupportYears} год поддержки уровня Enterprise — в комплекте с сервером (или ${rub(P.baseLicensePriceRub || 4_000_000)} за базовую лицензию без оборудования при установке через <code>npm</code>).</p>
|
|
265
|
-
<div class="hero-cta" style="margin-top:20px">
|
|
271
|
+
<div class="hero-cta" style="margin-top:20px;display:flex;gap:10px;flex-wrap:wrap">
|
|
266
272
|
<button class="btn btn-primary" data-stage="clients">Перейти к покупке →</button>
|
|
267
273
|
<button class="btn btn-ghost" data-stage="franchise">Региональная франшиза</button>
|
|
274
|
+
<button class="btn btn-ghost" data-action="open-presentation" data-slide="1">${icon('docs')} Презентация</button>
|
|
268
275
|
</div>
|
|
269
276
|
</div>
|
|
270
277
|
<dl class="spec">
|
|
@@ -332,7 +339,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
332
339
|
<span class="kicker">Тарифы поддержки и конкурентных мест</span>
|
|
333
340
|
<h2 class="h2">Условия лицензирования и SLA</h2>
|
|
334
341
|
</div>
|
|
335
|
-
<
|
|
342
|
+
<div>
|
|
343
|
+
<p style="margin:0 0 12px">Сразу после установки из <code>npm</code> система работает бесплатно в режиме настройки (1 сессия BOSS, ИИ-агенты отключены). Базовая постоянная лицензия открывает <b>${P.baseSeats} вечных одновременных сессий</b> и включает агентскую ИИ-систему.</p>
|
|
344
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
345
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="open-presentation" data-slide="8">${icon('docs')} Презентация для клиентов (слайд 8)</button>
|
|
346
|
+
<a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
336
349
|
</div>
|
|
337
350
|
<div class="tiers">${renderTiers()}</div>
|
|
338
351
|
</section>
|
|
@@ -501,7 +514,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
501
514
|
|
|
502
515
|
<div class="sec-head" style="margin-top:8px">
|
|
503
516
|
<div><span class="kicker">Партнёрская программа</span><h2 class="h2">Условия региональной франшизы ODANT-WORK</h2></div>
|
|
504
|
-
<
|
|
517
|
+
<div>
|
|
518
|
+
<p style="margin:0 0 12px">Число франшиз в субъекте пропорционально населению, паушальный взнос — экономике региона. Резерв закрепляется сразу после невозвратного залога ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}, полная оплата паушального взноса — в течение 1 года.</p>
|
|
519
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
520
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="open-presentation" data-slide="10">${icon('docs')} Презентация франшизы (слайд 10)</button>
|
|
521
|
+
<a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
505
524
|
</div>
|
|
506
525
|
|
|
507
526
|
<div class="split">
|
|
@@ -548,7 +567,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
548
567
|
<h4>${esc(VENDOR_CONTACTS.company)}</h4>
|
|
549
568
|
<p>${esc(VENDOR_CONTACTS.address)}</p>
|
|
550
569
|
<p>${VENDOR_CONTACTS.phones.map(esc).join(', ')}</p>
|
|
551
|
-
<p><a href="${esc(VENDOR_CONTACTS.site)}" target="_blank" rel="noopener">${esc(VENDOR_CONTACTS.site.replace(/^https?:\/\//, ''))}</a> · <a href="#" data-action="open-oferta">Публичная оферта</a></p>
|
|
570
|
+
<p><a href="${esc(VENDOR_CONTACTS.site)}" target="_blank" rel="noopener">${esc(VENDOR_CONTACTS.site.replace(/^https?:\/\//, ''))}</a> · <a href="#" data-action="open-oferta">Публичная оферта</a> · <a href="#" data-action="open-presentation" data-slide="1">Презентация</a> · <a href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf">PDF</a></p>
|
|
552
571
|
</div>
|
|
553
572
|
</div>
|
|
554
573
|
<div class="footer-bottom">
|
|
@@ -667,6 +686,50 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
667
686
|
</div>
|
|
668
687
|
</div>
|
|
669
688
|
|
|
689
|
+
<!-- Интерактивный просмотр презентации ODANT-WORK (для клиентов и франчайзи) -->
|
|
690
|
+
<div class="modal pres-modal" id="presentationModal" role="dialog" aria-modal="true" aria-labelledby="presModalTitle">
|
|
691
|
+
<div class="card pres-card">
|
|
692
|
+
<div class="pres-top">
|
|
693
|
+
<div class="pres-title-wrap">
|
|
694
|
+
<img src="/assets/odant-logo.png" alt="">
|
|
695
|
+
<div>
|
|
696
|
+
<b id="presModalTitle">Презентация ODANT-WORK</b>
|
|
697
|
+
<span class="small muted">Для клиентов и региональных партнёров (франчайзи)</span>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
<div class="pres-sections" id="presSectionTabs">
|
|
701
|
+
<button type="button" class="chip on" data-action="pres-goto" data-slide="1" data-sec="product">1. О платформе (1–7)</button>
|
|
702
|
+
<button type="button" class="chip" data-action="pres-goto" data-slide="8" data-sec="clients">2. Клиентам (8–9)</button>
|
|
703
|
+
<button type="button" class="chip" data-action="pres-goto" data-slide="10" data-sec="franchise">3. Франшиза (10–12)</button>
|
|
704
|
+
</div>
|
|
705
|
+
<div class="pres-actions">
|
|
706
|
+
<a class="btn btn-ghost btn-sm" href="/presentation" target="_blank" rel="noopener" style="text-decoration:none" title="Открыть в отдельном окне">${icon('expand')} Во весь экран</a>
|
|
707
|
+
<a class="btn btn-primary btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
|
|
708
|
+
<button type="button" class="icon-btn" data-action="pres-close" aria-label="Закрыть презентацию">${icon('close')}</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<div class="pres-stage">
|
|
713
|
+
<iframe id="presFrame" title="Слайды презентации ODANT-WORK" loading="lazy"></iframe>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div class="pres-bottom">
|
|
717
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
718
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="pres-prev">← Назад</button>
|
|
719
|
+
<span class="pres-counter" id="presCounter">Слайд 1 / 12</span>
|
|
720
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="pres-next">Вперёд →</button>
|
|
721
|
+
</div>
|
|
722
|
+
<div class="pres-dots" id="presDots">
|
|
723
|
+
${Array.from({ length: 12 }, (_, idx) => `<button type="button" class="pres-dot ${idx === 0 ? 'on' : ''}" data-action="pres-goto" data-slide="${idx + 1}" title="Слайд ${idx + 1}">${idx + 1}</button>`).join('')}
|
|
724
|
+
</div>
|
|
725
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
726
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="pres-jump-stage" data-target-stage="clients">К калькулятору клиентов →</button>
|
|
727
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="pres-jump-stage" data-target-stage="franchise">К карте франшиз РФ →</button>
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
|
|
670
733
|
<!-- ИИ-агент лицензионного центра -->
|
|
671
734
|
<button class="ai-fab" data-action="ai-toggle" aria-controls="aiDrawer"><img src="/assets/odant-logo.png" alt=""><span class="t">ИИ-агент ODANT</span><span class="live"></span></button>
|
|
672
735
|
<div class="ai-drawer" id="aiDrawer" aria-live="polite">
|
package/src/server.js
CHANGED
|
@@ -39,16 +39,14 @@ import {
|
|
|
39
39
|
calculateRegionPartnerQuota,
|
|
40
40
|
PUBLIC_OFERTA,
|
|
41
41
|
} from './catalog.js';
|
|
42
|
-
import { LicenseStore } from './store.js';
|
|
42
|
+
import { LicenseStore, resolvePersistentDataDir } from './store.js';
|
|
43
43
|
import { YooKassaService } from './yookassa.js';
|
|
44
44
|
import { SupportBotsHub } from './bots.js';
|
|
45
45
|
import { LicenseServerAiAgent } from './agent.js';
|
|
46
46
|
import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
|
|
47
47
|
import { renderAdminPage } from './admin.js';
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
async function readJsonBody(req, maxBytes = 1024 * 1024) {
|
|
49
|
+
async function readJsonBody(req, maxBytes = 16 * 1024 * 1024) {
|
|
52
50
|
const chunks = [];
|
|
53
51
|
let size = 0;
|
|
54
52
|
for await (const ch of req) {
|
|
@@ -82,6 +80,9 @@ const STATIC_ASSETS = Object.freeze({
|
|
|
82
80
|
'/assets/landing.css': { file: 'landing.css', type: 'text/css; charset=utf-8' },
|
|
83
81
|
'/assets/landing.js': { file: 'landing.js', type: 'text/javascript; charset=utf-8' },
|
|
84
82
|
'/assets/admin.js': { file: 'admin.js', type: 'text/javascript; charset=utf-8' },
|
|
83
|
+
'/presentation': { file: 'ODANT-WORK-presentation.html', type: 'text/html; charset=utf-8' },
|
|
84
|
+
'/assets/ODANT-WORK-presentation.html': { file: 'ODANT-WORK-presentation.html', type: 'text/html; charset=utf-8' },
|
|
85
|
+
'/assets/ODANT-WORK-presentation.pdf': { file: 'ODANT-WORK-presentation.pdf', type: 'application/pdf' },
|
|
85
86
|
});
|
|
86
87
|
const assetCache = new Map();
|
|
87
88
|
let assetVersionCache = '';
|
|
@@ -281,10 +282,12 @@ async function sendOtpEmail({ to, code }) {
|
|
|
281
282
|
export function createLicenseServerApp(options = {}) {
|
|
282
283
|
const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
|
|
283
284
|
const store = options.store || new LicenseStore({
|
|
284
|
-
dataDir: options.dataDir ||
|
|
285
|
+
dataDir: options.dataDir || resolvePersistentDataDir(),
|
|
285
286
|
adminLogin: options.adminLogin,
|
|
286
287
|
adminPassword: options.adminPassword,
|
|
287
288
|
publicOrigin: options.publicOrigin || 'https://work.odant.org',
|
|
289
|
+
autoRecover: options.autoRecover,
|
|
290
|
+
extraSearchRoots: options.extraSearchRoots,
|
|
288
291
|
});
|
|
289
292
|
let bots = null;
|
|
290
293
|
const yookassa = options.yookassa || new YooKassaService({
|
|
@@ -983,6 +986,8 @@ export function createLicenseServerApp(options = {}) {
|
|
|
983
986
|
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
984
987
|
const allFranchises = store.listAllFranchises();
|
|
985
988
|
return sendJson(res, 200, {
|
|
989
|
+
dataDir: store.dataDir,
|
|
990
|
+
counts: store.getStoreCounts(),
|
|
986
991
|
licenses: store.listLicenses(),
|
|
987
992
|
tickets: store.listTickets(),
|
|
988
993
|
orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
|
|
@@ -994,6 +999,40 @@ export function createLicenseServerApp(options = {}) {
|
|
|
994
999
|
});
|
|
995
1000
|
}
|
|
996
1001
|
|
|
1002
|
+
if (req.method === 'GET' && pathname === '/api/admin/backup/export') {
|
|
1003
|
+
const tok = extractBearerToken(req, null, url);
|
|
1004
|
+
if (!store.verifyAdminToken(tok))
|
|
1005
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1006
|
+
const bundle = store.exportBackupBundle();
|
|
1007
|
+
const dayStr = new Date().toISOString().slice(0, 10);
|
|
1008
|
+
const body = JSON.stringify(bundle, null, 2);
|
|
1009
|
+
res.writeHead(200, {
|
|
1010
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1011
|
+
'Content-Disposition': `attachment; filename="odant-work-license-backup-${dayStr}.json"`,
|
|
1012
|
+
'Access-Control-Allow-Origin': '*',
|
|
1013
|
+
});
|
|
1014
|
+
res.end(body);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (req.method === 'POST' && pathname === '/api/admin/backup/import') {
|
|
1019
|
+
const body = await readJsonBody(req);
|
|
1020
|
+
const tok = extractBearerToken(req, body, url);
|
|
1021
|
+
if (!store.verifyAdminToken(tok))
|
|
1022
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1023
|
+
const result = store.importBackupBundle(body.backup || body, { mode: body.mode || 'merge' });
|
|
1024
|
+
return sendJson(res, 200, result);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (req.method === 'POST' && pathname === '/api/admin/backup/recover') {
|
|
1028
|
+
const body = await readJsonBody(req);
|
|
1029
|
+
const tok = extractBearerToken(req, body, url);
|
|
1030
|
+
if (!store.verifyAdminToken(tok))
|
|
1031
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1032
|
+
const result = store.recoverFromExternalLocations({ extraSearchRoots: body.extraSearchRoots || [] });
|
|
1033
|
+
return sendJson(res, 200, result);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
997
1036
|
if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {
|
|
998
1037
|
const body = await readJsonBody(req);
|
|
999
1038
|
const tok = extractBearerToken(req, body, url);
|