v0.5.1: Первая версия
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
!/.gitignore
|
||||||
|
!/index.html
|
||||||
|
!/api.php
|
||||||
|
!/manifest.webmanifest
|
||||||
|
!/icons/
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
const D = __DIR__ . '/data/';
|
||||||
|
const LIST_TYPES = ['Коробка', 'Мешок', 'Полка'];
|
||||||
|
const COL_TYPES = ['text', 'number', 'date', 'select', 'check'];
|
||||||
|
const STD_IDS = ['name', 'qty', 'cat', 'loc'];
|
||||||
|
const ONLINE_SEC = 120;
|
||||||
|
|
||||||
|
if (!function_exists('mb_substr')) { function mb_substr($s, $a, $l = null) { preg_match_all('/./us', (string)$s, $m); return implode('', array_slice($m[0], $a, $l)); } }
|
||||||
|
if (!function_exists('mb_strlen')) { function mb_strlen($s) { return (int)preg_match_all('/./us', (string)$s); } }
|
||||||
|
if (!function_exists('mb_strtolower')) {
|
||||||
|
function mb_strtolower($s) {
|
||||||
|
static $m = null;
|
||||||
|
if ($m === null) $m = array_combine(preg_split('//u', 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ', -1, PREG_SPLIT_NO_EMPTY), preg_split('//u', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя', -1, PREG_SPLIT_NO_EMPTY));
|
||||||
|
return strtr(strtolower((string)$s), $m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiError extends Exception {}
|
||||||
|
function fail(string $m, int $c = 400): void { throw new ApiError($m, $c); }
|
||||||
|
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
set_error_handler(function ($no, $msg, $file, $line) { throw new ErrorException($msg, 0, $no, $file, $line); });
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
set_exception_handler(function ($e) {
|
||||||
|
if (!($e instanceof ApiError)) error_log('inventory api: ' . $e);
|
||||||
|
http_response_code($e instanceof ApiError ? ($e->getCode() ?: 400) : 500);
|
||||||
|
echo json_encode(['error' => $e instanceof ApiError ? $e->getMessage() : 'Ошибка сервера (подробности в логе PHP)'], JSON_UNESCAPED_UNICODE);
|
||||||
|
});
|
||||||
|
|
||||||
|
function out($d, int $c = 200): void { http_response_code($c); echo json_encode($d, JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function rd(string $f, $def) { $p = D . $f; if (!is_file($p)) return $def; $j = json_decode((string)file_get_contents($p), true); return $j === null ? $def : $j; }
|
||||||
|
function wr(string $f, $d): void {
|
||||||
|
$p = D . $f; $t = $p . '.tmp';
|
||||||
|
file_put_contents($t, json_encode($d, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
rename($t, $p);
|
||||||
|
}
|
||||||
|
function str($v, int $max): string { return is_scalar($v) ? mb_substr(trim((string)$v), 0, $max) : ''; }
|
||||||
|
function new_id(string $p = ''): string { return $p . bin2hex(random_bytes(5)); }
|
||||||
|
|
||||||
|
function load_ref(): array {
|
||||||
|
$r = rd('ref.json', []);
|
||||||
|
if (!isset($r['dicts'])) {
|
||||||
|
$r = ['dicts' => [
|
||||||
|
['id' => 'cats', 'name' => 'Категории', 'values' => array_values($r['cats'] ?? ['Категория 1', 'Категория 2', 'Категория 3'])],
|
||||||
|
['id' => 'locs', 'name' => 'Расположения', 'values' => array_values($r['locs'] ?? ['Склад', 'Дом', 'Воронеж'])],
|
||||||
|
], 'columns' => []];
|
||||||
|
}
|
||||||
|
$r['columns'] = $r['columns'] ?? [];
|
||||||
|
if (dict_idx($r, 'ltypes') < 0) $r['dicts'][] = ['id' => 'ltypes', 'name' => 'Типы списков', 'values' => LIST_TYPES];
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
function std_cols(): array {
|
||||||
|
return [
|
||||||
|
['id' => 'name', 'name' => 'Название', 'type' => 'text', 'dict' => null],
|
||||||
|
['id' => 'qty', 'name' => 'Кол-во', 'type' => 'number', 'dict' => null],
|
||||||
|
['id' => 'cat', 'name' => 'Категория', 'type' => 'select', 'dict' => 'cats'],
|
||||||
|
['id' => 'loc', 'name' => 'Расположение', 'type' => 'select', 'dict' => 'locs'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function cols_map(array $ref): array {
|
||||||
|
$m = [];
|
||||||
|
foreach (array_merge(std_cols(), $ref['columns']) as $c) $m[$c['id']] = $c;
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
function ltypes(array $ref): array { $i = dict_idx($ref, 'ltypes'); return $i < 0 ? LIST_TYPES : $ref['dicts'][$i]['values']; }
|
||||||
|
function ref_values(array $ref, string $id): array { $i = dict_idx($ref, $id); return $i < 0 ? [] : $ref['dicts'][$i]['values']; }
|
||||||
|
function dict_idx(array $ref, string $id): int {
|
||||||
|
foreach ($ref['dicts'] as $i => $d) if ($d['id'] === $id) return $i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm_list(array $l): array {
|
||||||
|
if (!isset($l['kind'])) {
|
||||||
|
$l['kind'] = 'standard';
|
||||||
|
$l['cols'] = STD_IDS;
|
||||||
|
$l['ltype'] = (($l['type'] ?? '') === 'box') ? 'Коробка' : '';
|
||||||
|
$items = [];
|
||||||
|
foreach ($l['items'] ?? [] as $it) {
|
||||||
|
$items[] = ['id' => (string)($it['id'] ?? new_id()), 'v' => [
|
||||||
|
'name' => (string)($it['name'] ?? ''), 'qty' => (string)($it['qty'] ?? ''),
|
||||||
|
'cat' => (string)($it['cat'] ?? ''), 'loc' => (string)($it['loc'] ?? '')]];
|
||||||
|
}
|
||||||
|
$l['items'] = $items;
|
||||||
|
unset($l['type']);
|
||||||
|
}
|
||||||
|
$l['ltype'] = $l['ltype'] ?? '';
|
||||||
|
return $l;
|
||||||
|
}
|
||||||
|
function load_lists(): array { return array_map('norm_list', rd('lists.json', [])); }
|
||||||
|
function list_idx(array $lists, string $id): int {
|
||||||
|
foreach ($lists as $i => $l) if ($l['id'] === $id) return $i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
function same_cols(array $a, array $b): bool {
|
||||||
|
$a = $a['cols']; $b = $b['cols']; sort($a); sort($b);
|
||||||
|
return $a === $b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function map_items(array $items, array $dst, array $ref): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$v = [];
|
||||||
|
foreach ($dst['cols'] as $c) $v[$c] = (string)($it['v'][$c] ?? '');
|
||||||
|
if ($dst['kind'] === 'standard') {
|
||||||
|
if ($v['name'] === '') $v['name'] = 'Без названия';
|
||||||
|
if ($v['cat'] === '') $v['cat'] = (string)(ref_values($ref, 'cats')[0] ?? '');
|
||||||
|
if ($v['loc'] === '') $v['loc'] = (string)(ref_values($ref, 'locs')[0] ?? '');
|
||||||
|
}
|
||||||
|
$out[] = ['id' => new_id(), 'v' => $v];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
function clean_val(array $col, $v, array $ref): string {
|
||||||
|
$v = is_scalar($v) ? trim((string)$v) : '';
|
||||||
|
switch ($col['type']) {
|
||||||
|
case 'text':
|
||||||
|
return mb_substr($v, 0, 200);
|
||||||
|
case 'number':
|
||||||
|
if ($v === '') return '';
|
||||||
|
if (!is_numeric($v) || strlen($v) > 20) fail('«' . $col['name'] . '»: нужно число');
|
||||||
|
return $v;
|
||||||
|
case 'date':
|
||||||
|
if ($v === '') return '';
|
||||||
|
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m) || !checkdate((int)$m[2], (int)$m[3], (int)$m[1])) fail('«' . $col['name'] . '»: неверная дата');
|
||||||
|
return $v;
|
||||||
|
case 'check':
|
||||||
|
return ($v === '1' || $v === 'true') ? '1' : '';
|
||||||
|
case 'select':
|
||||||
|
if ($v === '') return '';
|
||||||
|
$di = dict_idx($ref, (string)$col['dict']);
|
||||||
|
if ($di < 0 || !in_array($v, $ref['dicts'][$di]['values'], true)) fail('«' . $col['name'] . '»: значения «' . mb_substr($v, 0, 40) . '» нет в справочнике');
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function clean_row($raw, array $cols, array $cm, array $ref, string $kind): array {
|
||||||
|
$raw = is_array($raw) ? $raw : [];
|
||||||
|
$v = []; $any = false;
|
||||||
|
foreach ($cols as $c) {
|
||||||
|
$v[$c] = clean_val($cm[$c], $raw[$c] ?? '', $ref);
|
||||||
|
if ($v[$c] !== '') $any = true;
|
||||||
|
}
|
||||||
|
if ($kind === 'standard') {
|
||||||
|
if ($v['name'] === '') fail('У предмета должно быть название');
|
||||||
|
if ($v['cat'] === '' || $v['loc'] === '') fail('Укажите категорию и расположение');
|
||||||
|
} elseif (!$any) fail('Строка пустая');
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
function clean_list(array $l, array $ref, ?array $old): array {
|
||||||
|
$id = (string)($l['id'] ?? '');
|
||||||
|
if (!preg_match('/^[a-z0-9]{3,20}$/', $id)) fail('Некорректный список');
|
||||||
|
$cm = cols_map($ref);
|
||||||
|
$lt = str($l['ltype'] ?? '', 60);
|
||||||
|
$oldLt = $old ? (string)($old['ltype'] ?? '') : '';
|
||||||
|
$ltype = ($lt === '' || in_array($lt, ltypes($ref), true) || $lt === $oldLt) ? $lt : $oldLt;
|
||||||
|
if ($old) {
|
||||||
|
$kind = $old['kind']; $cols = $old['cols'];
|
||||||
|
} else {
|
||||||
|
$kind = (($l['kind'] ?? '') === 'custom') ? 'custom' : 'standard';
|
||||||
|
if ($kind === 'standard') $cols = STD_IDS;
|
||||||
|
else {
|
||||||
|
$cols = array_values(array_unique(array_map('strval', (array)($l['cols'] ?? []))));
|
||||||
|
if (!$cols || count($cols) > 12) fail('Выберите от 1 до 12 колонок');
|
||||||
|
foreach ($cols as $c) if (!isset($cm[$c])) fail('Неизвестная колонка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$src = $l['items'] ?? null;
|
||||||
|
if (!is_array($src) || count($src) > 5000) fail('Некорректный список предметов');
|
||||||
|
$items = [];
|
||||||
|
foreach ($src as $it) {
|
||||||
|
$iid = substr(preg_replace('/[^a-z0-9]/', '', strtolower((string)($it['id'] ?? ''))), 0, 20);
|
||||||
|
$items[] = ['id' => $iid !== '' ? $iid : new_id(), 'v' => clean_row($it['v'] ?? [], $cols, $cm, $ref, $kind)];
|
||||||
|
}
|
||||||
|
return ['id' => $id, 'name' => str($l['name'] ?? '', 100) ?: 'Список', 'kind' => $kind, 'ltype' => $ltype,
|
||||||
|
'cols' => $cols, 'final' => !empty($l['final']), 'items' => $items];
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch_presence(string $u): void {
|
||||||
|
$pr = rd('presence.json', []); $now = time();
|
||||||
|
if (($pr[$u] ?? 0) > $now - 15) return;
|
||||||
|
$pr[$u] = $now;
|
||||||
|
foreach ($pr as $k => $t) if ($t < $now - 86400) unset($pr[$k]);
|
||||||
|
wr('presence.json', $pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function session_info(): array {
|
||||||
|
return ['user' => ['login' => $_SESSION['u'], 'role' => $_SESSION['role']], 'csrf' => $_SESSION['csrf'], 'mustChange' => !empty($_SESSION['must'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
session_name('inv');
|
||||||
|
session_set_cookie_params(['httponly' => true, 'samesite' => 'Strict', 'secure' => !empty($_SERVER['HTTPS'])]);
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') out(['error' => 'POST only'], 405);
|
||||||
|
$in = json_decode((string)file_get_contents('php://input'), true) ?: [];
|
||||||
|
$a = (string)($in['a'] ?? '');
|
||||||
|
|
||||||
|
$lock = fopen(D . '.lock', 'c'); flock($lock, LOCK_EX);
|
||||||
|
|
||||||
|
if ($a === 'login') {
|
||||||
|
$login = mb_strtolower(str($in['login'] ?? '', 50));
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '?';
|
||||||
|
$buckets = [[hash('sha256', "$ip|$login"), 5], [hash('sha256', $ip), 20]];
|
||||||
|
$at = rd('attempts.json', []); $now = time();
|
||||||
|
foreach ($at as $k => $v) if (($v['t'] ?? 0) < $now - 3600 && ($v['until'] ?? 0) < $now) unset($at[$k]);
|
||||||
|
foreach ($buckets as [$k]) if (($at[$k]['until'] ?? 0) > $now) { wr('attempts.json', $at); out(['error' => 'Слишком много попыток. Подождите несколько минут.'], 429); }
|
||||||
|
$users = rd('users.json', ['users' => []]); $u = null; $ui = -1;
|
||||||
|
foreach ($users['users'] as $i => $x) if ($x['login'] === $login) { $u = $x; $ui = $i; }
|
||||||
|
$pw = (string)($in['password'] ?? '');
|
||||||
|
$ok = password_verify($pw, $u['hash'] ?? password_hash('dummy', PASSWORD_DEFAULT)) && $u !== null;
|
||||||
|
if (!$ok) {
|
||||||
|
foreach ($buckets as [$k, $lim]) {
|
||||||
|
$n = ($at[$k]['n'] ?? 0) + 1;
|
||||||
|
$at[$k] = ['n' => $n, 't' => $now, 'until' => $n >= $lim ? $now + 600 : 0];
|
||||||
|
if ($n >= $lim) $at[$k]['n'] = 0;
|
||||||
|
}
|
||||||
|
wr('attempts.json', $at); usleep(500000);
|
||||||
|
out(['error' => 'Неверный логин или пароль'], 401);
|
||||||
|
}
|
||||||
|
foreach ($buckets as [$k]) unset($at[$k]);
|
||||||
|
wr('attempts.json', $at);
|
||||||
|
if (password_needs_rehash($u['hash'], PASSWORD_DEFAULT)) { $users['users'][$ui]['hash'] = password_hash($pw, PASSWORD_DEFAULT); wr('users.json', $users); }
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION = ['u' => $u['login'], 'role' => $u['role'], 'csrf' => bin2hex(random_bytes(16)), 'must' => !empty($u['must_change'])];
|
||||||
|
touch_presence($u['login']);
|
||||||
|
out(session_info());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['u'])) out(['error' => 'auth'], 401);
|
||||||
|
touch_presence((string)$_SESSION['u']);
|
||||||
|
if ($a === 'me') out(session_info());
|
||||||
|
if (!hash_equals($_SESSION['csrf'], (string)($_SERVER['HTTP_X_CSRF'] ?? ''))) out(['error' => 'CSRF'], 403);
|
||||||
|
$admin = $_SESSION['role'] === 'admin';
|
||||||
|
if (!empty($_SESSION['must']) && !in_array($a, ['logout', 'passwd'], true)) fail('Сначала смените пароль', 403);
|
||||||
|
$ref = load_ref();
|
||||||
|
|
||||||
|
switch ($a) {
|
||||||
|
case 'ping':
|
||||||
|
out(['ok' => true]);
|
||||||
|
|
||||||
|
case 'logout':
|
||||||
|
$pr = rd('presence.json', []); unset($pr[$_SESSION['u']]); wr('presence.json', $pr);
|
||||||
|
$_SESSION = []; session_destroy(); out(['ok' => true]);
|
||||||
|
|
||||||
|
case 'passwd':
|
||||||
|
$users = rd('users.json', ['users' => []]);
|
||||||
|
$new = (string)($in['new'] ?? '');
|
||||||
|
$oldPw = (string)($in['old'] ?? '');
|
||||||
|
if (mb_strlen($new) < 8) fail('Новый пароль — не короче 8 символов');
|
||||||
|
if ($new === $oldPw) fail('Новый пароль должен отличаться от текущего');
|
||||||
|
foreach ($users['users'] as $i => $x) if ($x['login'] === $_SESSION['u']) {
|
||||||
|
if (!password_verify($oldPw, $x['hash'])) fail('Неверный текущий пароль', 403);
|
||||||
|
$users['users'][$i]['hash'] = password_hash($new, PASSWORD_DEFAULT);
|
||||||
|
unset($users['users'][$i]['must_change']);
|
||||||
|
wr('users.json', $users); $_SESSION['must'] = false; out(['ok' => true]);
|
||||||
|
}
|
||||||
|
fail('Пользователь не найден', 404);
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
$r = ['ref' => $ref, 'std' => std_cols(), 'listTypes' => ltypes($ref)];
|
||||||
|
if (!$admin) $r['lists'] = load_lists();
|
||||||
|
out($r);
|
||||||
|
|
||||||
|
case 'users':
|
||||||
|
if (!$admin) fail('forbidden', 403);
|
||||||
|
$pr = rd('presence.json', []); $now = time(); $res = [];
|
||||||
|
foreach (rd('users.json', ['users' => []])['users'] as $x)
|
||||||
|
$res[] = ['login' => $x['login'], 'role' => $x['role'] ?? 'user', 'online' => ($pr[$x['login']] ?? 0) > $now - ONLINE_SEC];
|
||||||
|
out(['users' => $res]);
|
||||||
|
|
||||||
|
case 'user_add':
|
||||||
|
if (!$admin) fail('forbidden', 403);
|
||||||
|
$nl = mb_strtolower(str($in['login'] ?? '', 30)); $np = (string)($in['password'] ?? '');
|
||||||
|
if (!preg_match('/^[a-z0-9_.-]{2,30}$/', $nl)) fail('Логин: 2–30 символов, латинские буквы, цифры и знаки _ . -');
|
||||||
|
if (mb_strlen($np) < 8) fail('Пароль — не короче 8 символов');
|
||||||
|
$users = rd('users.json', ['users' => []]);
|
||||||
|
foreach ($users['users'] as $x) if ($x['login'] === $nl) fail('Такой логин уже есть');
|
||||||
|
if (count($users['users']) >= 50) fail('Слишком много пользователей');
|
||||||
|
$users['users'][] = ['login' => $nl, 'role' => 'user', 'hash' => password_hash($np, PASSWORD_DEFAULT)];
|
||||||
|
wr('users.json', $users); out(['ok' => true]);
|
||||||
|
|
||||||
|
case 'list_save':
|
||||||
|
if ($admin) fail('forbidden', 403);
|
||||||
|
$lists = load_lists();
|
||||||
|
$id = (string)($in['list']['id'] ?? '');
|
||||||
|
$i = list_idx($lists, $id);
|
||||||
|
$new = clean_list((array)($in['list'] ?? []), $ref, $i >= 0 ? $lists[$i] : null);
|
||||||
|
if ($i >= 0) $lists[$i] = $new; else array_unshift($lists, $new);
|
||||||
|
wr('lists.json', $lists); out(['ok' => true, 'list' => $new]);
|
||||||
|
|
||||||
|
case 'list_delete':
|
||||||
|
if ($admin) fail('forbidden', 403);
|
||||||
|
wr('lists.json', array_values(array_filter(load_lists(), fn($x) => $x['id'] !== ($in['id'] ?? ''))));
|
||||||
|
out(['ok' => true]);
|
||||||
|
|
||||||
|
case 'transfer':
|
||||||
|
if ($admin) fail('forbidden', 403);
|
||||||
|
$lists = load_lists();
|
||||||
|
$si = list_idx($lists, (string)($in['from'] ?? ''));
|
||||||
|
if ($si < 0) fail('Исходный список не найден', 404);
|
||||||
|
$ids = array_map('strval', (array)($in['ids'] ?? []));
|
||||||
|
$picked = array_values(array_filter($lists[$si]['items'], fn($it) => in_array($it['id'], $ids, true)));
|
||||||
|
if (!$picked) fail('Не выбрано ни одного предмета');
|
||||||
|
$copy = fn() => array_map(fn($it) => ['id' => new_id(), 'v' => $it['v']], $picked);
|
||||||
|
$move = !empty($in['move']);
|
||||||
|
if ($move && !empty($lists[$si]['final'])) fail('Исходный список закрыт итогом — перенос с удалением невозможен');
|
||||||
|
$to = (array)($in['to'] ?? []);
|
||||||
|
if (isset($to['existing'])) {
|
||||||
|
$ti = list_idx($lists, (string)$to['existing']);
|
||||||
|
if ($ti < 0 || $ti === $si) fail('Список назначения не найден', 404);
|
||||||
|
if (!empty($lists[$ti]['final'])) fail('Список назначения закрыт итогом');
|
||||||
|
if (!in_array('name', $lists[$si]['cols'], true) || !in_array('name', $lists[$ti]['cols'], true)) fail('В обоих списках должна быть колонка «Название»');
|
||||||
|
if (!same_cols($lists[$si], $lists[$ti]) && empty($in['confirm'])) fail('Колонки списков отличаются, нужно подтверждение');
|
||||||
|
if (count($lists[$ti]['items']) + count($picked) > 5000) fail('Слишком много предметов в списке');
|
||||||
|
$lists[$ti]['items'] = array_merge($lists[$ti]['items'], map_items($picked, $lists[$ti], $ref));
|
||||||
|
$target = $lists[$ti]['id'];
|
||||||
|
} else {
|
||||||
|
$name = str($to['name'] ?? '', 100);
|
||||||
|
$lt = (string)($to['ltype'] ?? '');
|
||||||
|
if ($name === '') fail('Укажите название списка');
|
||||||
|
if (!in_array($lt, ltypes($ref), true)) fail('Выберите тип списка');
|
||||||
|
$nl = ['id' => new_id('l'), 'name' => $name, 'kind' => $lists[$si]['kind'], 'ltype' => $lt,
|
||||||
|
'cols' => $lists[$si]['cols'], 'final' => false, 'items' => $copy()];
|
||||||
|
array_unshift($lists, $nl); $si++;
|
||||||
|
$target = $nl['id'];
|
||||||
|
}
|
||||||
|
if ($move) {
|
||||||
|
$lists[$si]['items'] = array_values(array_filter($lists[$si]['items'], fn($it) => !in_array($it['id'], $ids, true)));
|
||||||
|
}
|
||||||
|
wr('lists.json', $lists);
|
||||||
|
out(['ok' => true, 'target' => $target, 'lists' => $lists]);
|
||||||
|
|
||||||
|
case 'dict_op':
|
||||||
|
if (!$admin) fail('forbidden', 403);
|
||||||
|
$op = (string)($in['op'] ?? ''); $did = (string)($in['id'] ?? '');
|
||||||
|
$v = str($in['value'] ?? '', 60); $old = (string)($in['old'] ?? '');
|
||||||
|
if ($op === 'add_dict') {
|
||||||
|
if ($v === '') fail('Введите название справочника');
|
||||||
|
foreach ($ref['dicts'] as $d) if (mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть');
|
||||||
|
if (count($ref['dicts']) >= 50) fail('Слишком много справочников');
|
||||||
|
$ref['dicts'][] = ['id' => new_id('d'), 'name' => $v, 'values' => []];
|
||||||
|
wr('ref.json', $ref); out(['ref' => $ref]);
|
||||||
|
}
|
||||||
|
$di = dict_idx($ref, $did);
|
||||||
|
if ($di < 0) fail('Справочник не найден', 404);
|
||||||
|
$vals = $ref['dicts'][$di]['values'];
|
||||||
|
$core = in_array($did, ['cats', 'locs', 'ltypes'], true);
|
||||||
|
$usedCols = array_keys(array_filter(cols_map($ref), fn($c) => $c['type'] === 'select' && $c['dict'] === $did));
|
||||||
|
$lists = load_lists();
|
||||||
|
switch ($op) {
|
||||||
|
case 'rename_dict':
|
||||||
|
if ($v === '') fail('Введите название');
|
||||||
|
foreach ($ref['dicts'] as $d) if ($d['id'] !== $did && mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть');
|
||||||
|
$ref['dicts'][$di]['name'] = $v; break;
|
||||||
|
case 'delete_dict':
|
||||||
|
if ($core) fail('Встроенный справочник удалить нельзя');
|
||||||
|
if ($usedCols) fail('Справочник используется в колонках — сначала удалите или измените их');
|
||||||
|
array_splice($ref['dicts'], $di, 1); break;
|
||||||
|
case 'add_value':
|
||||||
|
if ($v === '' || in_array($v, $vals, true)) fail('Пустое или повторяющееся значение');
|
||||||
|
$ref['dicts'][$di]['values'][] = $v; break;
|
||||||
|
case 'rename_value':
|
||||||
|
$i = array_search($old, $vals, true);
|
||||||
|
if ($i === false || $v === '' || ($v !== $old && in_array($v, $vals, true))) fail('Пустое или повторяющееся значение');
|
||||||
|
$ref['dicts'][$di]['values'][$i] = $v;
|
||||||
|
foreach ($lists as &$l) foreach ($l['items'] as &$it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) $it['v'][$c] = $v;
|
||||||
|
unset($l, $it);
|
||||||
|
if ($did === 'ltypes') foreach ($lists as &$l) if (($l['ltype'] ?? '') === $old) $l['ltype'] = $v;
|
||||||
|
unset($l); wr('lists.json', $lists); break;
|
||||||
|
case 'delete_value':
|
||||||
|
$i = array_search($old, $vals, true);
|
||||||
|
if ($i === false) fail('Не найдено', 404);
|
||||||
|
if ($core && count($vals) < 2) fail('Нельзя удалить последнее значение');
|
||||||
|
if ($did === 'ltypes') foreach ($lists as $l) if (($l['ltype'] ?? '') === $old) fail("Тип «{$old}» назначен спискам (например, «{$l['name']}»). Сначала смените им тип.");
|
||||||
|
foreach ($lists as $l) foreach ($l['items'] as $it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) fail("«{$old}» используется в предметах");
|
||||||
|
array_splice($ref['dicts'][$di]['values'], (int)$i, 1); break;
|
||||||
|
default: fail('op');
|
||||||
|
}
|
||||||
|
wr('ref.json', $ref); out(['ref' => $ref]);
|
||||||
|
|
||||||
|
case 'col_op':
|
||||||
|
if (!$admin) fail('forbidden', 403);
|
||||||
|
$op = (string)($in['op'] ?? ''); $cid = (string)($in['id'] ?? '');
|
||||||
|
$name = str($in['name'] ?? '', 60); $type = (string)($in['type'] ?? ''); $dict = $in['dict'] ?? null;
|
||||||
|
$lists = load_lists();
|
||||||
|
$usedIn = function (string $id) use ($lists) { return array_values(array_map(fn($l) => $l['name'], array_filter($lists, fn($l) => in_array($id, $l['cols'], true)))); };
|
||||||
|
$checkName = function () use ($ref, $name, $cid) {
|
||||||
|
if ($name === '') fail('Введите название колонки');
|
||||||
|
foreach (cols_map($ref) as $c) if ($c['id'] !== $cid && mb_strtolower($c['name']) === mb_strtolower($name)) fail('Колонка с таким названием уже есть');
|
||||||
|
};
|
||||||
|
$checkType = function () use ($ref, $type, &$dict) {
|
||||||
|
if (!in_array($type, COL_TYPES, true)) fail('Неверный тип колонки');
|
||||||
|
if ($type === 'select') { if ((string)$dict === 'ltypes' || dict_idx($ref, (string)$dict) < 0) fail('Выберите справочник для списка'); $dict = (string)$dict; }
|
||||||
|
else $dict = null;
|
||||||
|
};
|
||||||
|
if ($op === 'add') {
|
||||||
|
$checkName(); $checkType();
|
||||||
|
if (count($ref['columns']) >= 50) fail('Слишком много колонок');
|
||||||
|
$ref['columns'][] = ['id' => new_id('c'), 'name' => $name, 'type' => $type, 'dict' => $dict];
|
||||||
|
} else {
|
||||||
|
$ci = -1; foreach ($ref['columns'] as $i => $c) if ($c['id'] === $cid) $ci = $i;
|
||||||
|
if ($ci < 0) fail('Колонка не найдена', 404);
|
||||||
|
if ($op === 'update') {
|
||||||
|
$checkName();
|
||||||
|
$c = $ref['columns'][$ci];
|
||||||
|
$dict = $dict === '' ? null : $dict;
|
||||||
|
if ($type !== $c['type'] || ($type === 'select' && (string)$dict !== (string)$c['dict'])) {
|
||||||
|
if ($u = $usedIn($cid)) fail('Тип колонки нельзя менять: она уже используется в списках (' . implode(', ', array_slice($u, 0, 3)) . ')');
|
||||||
|
$checkType();
|
||||||
|
$ref['columns'][$ci]['type'] = $type; $ref['columns'][$ci]['dict'] = $dict;
|
||||||
|
}
|
||||||
|
$ref['columns'][$ci]['name'] = $name;
|
||||||
|
} elseif ($op === 'delete') {
|
||||||
|
if ($u = $usedIn($cid)) fail('Колонка используется в списках: ' . implode(', ', array_slice($u, 0, 3)));
|
||||||
|
array_splice($ref['columns'], $ci, 1);
|
||||||
|
} else fail('op');
|
||||||
|
}
|
||||||
|
wr('ref.json', $ref); out(['ref' => $ref]);
|
||||||
|
|
||||||
|
default: fail('unknown');
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplMjA2MjUxMy03MmI2LTQ0NTUtODY5ZC0xNjBlOTIzNTQ2YTgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaJdrAJ9pjMJgmDwhoEK922sAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo3YTY2ODQ4My03ZTdiLTQ4ZDQtYmIxYy03YmNiMmZmZjQyYWZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo9AqJugjLK9Tv/YAI1ABNiwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggCHVhv9Wa+acWK2oozzvNikmNwIltospBqVgUpTLKYSqkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaEfup5O1mG+ZNM6zR6v68J4AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCAq9XrR10PKA1R/sEMn0TuNePa93vZiF5gt00g7qOhVzmRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiiZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmUyMDYyNTEzLTcyYjYtNDQ1NS04NjlkLTE2MGU5MjM1NDZhOC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmY3ZDdiZWRjLTdjMmQtNGYzNy04MmEyLTBjY2VhOGQ5MzMxZHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAIdWG/1Zr5pxYraijPO82KSY3AiW2iykGpWBSlMsphKqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgg5kL1Q39WQLzUai1hYfRLmWUxowBTRxqDhxvbY9ISIAaiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6Qfv+m1sS8HxvFyILudyNEAEc8MDNPI9sgYGcc4kdI10Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
|
||||||
|
<rect width="512" height="512" rx="112" fill="#F6EFDF"/>
|
||||||
|
<path d="M88 176L256 260V432L94 351C90 349 88 345.667 88 341V176Z" fill="#BC8954"/>
|
||||||
|
<path d="M256 260L424 176V341C424 345.667 422 349 418 351L256 432V260Z" fill="#895A35"/>
|
||||||
|
<path d="M94 173L250 95C254 93 258 93 262 95L418 173C422 175 422 177 418 179L256 260L94 179C90 177 90 175 94 173Z" fill="#DAAA73"/>
|
||||||
|
<path d="M319.84 123.92L353.44 140.72L185.44 224.72L151.84 207.92L319.84 123.92Z" fill="#F6DEB4"/>
|
||||||
|
<path d="M151.84 207.92L185.44 224.72V269.72L151.84 252.92V207.92Z" fill="#E8C899"/>
|
||||||
|
<path d="M268.6 268.32L332.44 236.4V262.2L292.96 281.94V312.9L326.56 296.1V321.9L292.96 338.7V373.1L332.44 353.36V379.16L268.6 411.08V268.32Z" fill="#FFF4E2"/>
|
||||||
|
<path d="M344.2 230.52L411.4 196.92V222.72L371.92 333.62L411.4 313.88V339.68L344.2 373.28V347.48L383.68 236.58L344.2 256.32V230.52Z" fill="#FFF4E2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,641 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>inventorEZ</title>
|
||||||
|
<meta name="theme-color" content="#f6efdf">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6efdf;--card:#fffaf0;--tx:#2b2218;--mu:#7d6c58;--ac:#895a35;--on:#fff;--bd:#e2d4b8;--hl:#f3e5c8;--er:#b83a2a}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#222926;--card:#2c3532;--tx:#f6efdf;--mu:#a99f8c;--ac:#c99560;--on:#231a10;--bd:#3d4743;--hl:#37433e;--er:#e57368}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--tx);font:15px/1.4 system-ui,sans-serif}
|
||||||
|
.wrap{display:flex;gap:16px;max-width:1150px;margin:0 auto;padding:16px;align-items:flex-start}
|
||||||
|
main{flex:1;min-width:0}
|
||||||
|
aside{width:250px;background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:12px;position:sticky;top:16px}
|
||||||
|
h1{font-size:20px;margin:0 0 12px}h2{font-size:16px;margin:0}
|
||||||
|
aside h2{font-size:14px;margin:0 0 8px;color:var(--mu);text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:12px;overflow-x:auto;margin-bottom:16px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{text-align:left;padding:8px;border-bottom:1px solid var(--bd);vertical-align:middle}
|
||||||
|
th{color:var(--mu);font-weight:600;font-size:13px}
|
||||||
|
td.a{width:100px;text-align:right;white-space:nowrap}
|
||||||
|
td.c,th.c{width:28px;padding-right:0}
|
||||||
|
input,select{font:inherit;padding:6px 8px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--tx);width:100%;min-width:60px}
|
||||||
|
input[type=checkbox],input[type=radio]{width:auto;min-width:0}
|
||||||
|
button{white-space:nowrap;font:inherit;cursor:pointer;border:1px solid var(--bd);background:var(--card);color:var(--tx);border-radius:6px;padding:6px 12px}
|
||||||
|
button.p{background:var(--ac);border-color:var(--ac);color:var(--on)}
|
||||||
|
button.p:hover:not(:disabled){filter:brightness(1.1)}
|
||||||
|
button.i{padding:4px 8px;border:none;background:none}
|
||||||
|
button:disabled{opacity:.5;cursor:default}
|
||||||
|
.bar{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}.sp{flex:1}
|
||||||
|
tr.ed td{background:var(--hl)}
|
||||||
|
.li{display:flex;align-items:center;gap:4px;padding:6px 8px;border-radius:6px;cursor:pointer}
|
||||||
|
.li:hover,.li.on{background:var(--hl)}.li.on{font-weight:600}
|
||||||
|
.li span{flex:1;min-width:0;overflow-wrap:anywhere}
|
||||||
|
.tag{font-size:11px;color:var(--mu);font-weight:400}
|
||||||
|
.badge{display:inline-block;font-size:12px;padding:2px 8px;border-radius:10px;background:var(--hl);color:var(--ac);border:1px solid var(--bd);white-space:nowrap}
|
||||||
|
.title{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.title input{font-size:18px;font-weight:600;flex:1;min-width:160px;width:auto}
|
||||||
|
.empty{color:var(--mu);padding:16px 8px;text-align:center}
|
||||||
|
.dd{position:relative}.menu{display:none;position:absolute;right:0;top:100%;margin-top:4px;background:var(--card);border:1px solid var(--bd);border-radius:8px;min-width:290px;z-index:5;padding:4px;box-shadow:0 6px 20px #0003}
|
||||||
|
.menu.open{display:block}.menu button{display:block;width:100%;text-align:left;border:none;background:none;padding:8px 10px}
|
||||||
|
.menu button:hover:not(:disabled){background:var(--hl)}
|
||||||
|
.qr{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:16px}.qr img{width:200px;height:auto;background:#fff;border-radius:6px;border:1px solid var(--bd)}
|
||||||
|
#login{max-width:320px;margin:15vh auto;background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:20px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
#lerr{color:var(--er);min-height:1.2em;font-size:13px}
|
||||||
|
#top{max-width:1150px;margin:0 auto;padding:12px 16px 0;display:flex;gap:8px;align-items:center;color:var(--mu);font-size:13px}
|
||||||
|
.brand{display:flex;align-items:center;gap:8px;font-size:22px;font-weight:700;letter-spacing:-.02em;color:var(--tx)}
|
||||||
|
.brand svg{width:36px;height:36px;flex:none}.brand b{color:var(--ac);font-weight:800}
|
||||||
|
#login .brand{justify-content:center;font-size:28px;margin-bottom:6px}#login .brand svg{width:52px;height:52px}
|
||||||
|
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--ac);color:var(--on);padding:10px 16px;border-radius:8px;display:none;z-index:30;cursor:pointer}
|
||||||
|
.ov{position:fixed;inset:0;background:#0007;display:none;align-items:flex-start;justify-content:center;padding:6vh 12px;z-index:20;overflow:auto}
|
||||||
|
.mc{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:18px;width:100%;max-width:480px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.mc h2{font-size:18px}.mc label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mu)}
|
||||||
|
.mc .row{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
|
||||||
|
.opt{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border:1px solid var(--bd);border-radius:8px;cursor:pointer;color:var(--tx);font-size:14px}
|
||||||
|
.opt.on{border-color:var(--ac);background:var(--hl)}.opt input{margin-top:3px}
|
||||||
|
.opt small{display:block;color:var(--mu)}
|
||||||
|
.mc label.opt{flex-direction:row;color:var(--tx);font-size:14px}
|
||||||
|
.chk{display:flex;gap:8px;align-items:center;color:var(--tx)!important;font-size:14px!important;flex-direction:row!important}
|
||||||
|
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--bd)}
|
||||||
|
.tabs button{border:none;border-bottom:3px solid transparent;border-radius:0;background:none;padding:8px 14px;color:var(--mu)}
|
||||||
|
.tabs button.on{border-color:var(--ac);color:var(--tx);font-weight:600}
|
||||||
|
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;margin-right:8px}.dot.on{background:#2ecc71;box-shadow:0 0 0 3px #2ecc7133}
|
||||||
|
.chips{display:flex;gap:6px;flex-wrap:wrap}.note{color:var(--mu);font-size:13px;margin:0 0 10px}
|
||||||
|
.dh{display:flex;gap:8px;align-items:center}.dh+table{margin-top:8px}.dh input{font-weight:600}
|
||||||
|
.fld{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mu);margin-bottom:10px;max-width:380px}
|
||||||
|
@media(max-width:760px){.wrap{flex-direction:column-reverse}aside{width:100%;position:static}.who{display:none}#top{flex-wrap:wrap}.brand{font-size:19px}.brand svg{width:30px;height:30px}}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="logo" viewBox="0 0 512 512"><path d="M88 176L256 260V432L94 351C90 349 88 345.667 88 341V176Z" fill="#BC8954"/><path d="M256 260L424 176V341C424 345.667 422 349 418 351L256 432V260Z" fill="#895A35"/><path d="M94 173L250 95C254 93 258 93 262 95L418 173C422 175 422 177 418 179L256 260L94 179C90 177 90 175 94 173Z" fill="#DAAA73"/><path d="M319.84 123.92L353.44 140.72L185.44 224.72L151.84 207.92L319.84 123.92Z" fill="#F6DEB4"/><path d="M151.84 207.92L185.44 224.72V269.72L151.84 252.92V207.92Z" fill="#E8C899"/><path d="M268.6 268.32L332.44 236.4V262.2L292.96 281.94V312.9L326.56 296.1V321.9L292.96 338.7V373.1L332.44 353.36V379.16L268.6 411.08V268.32Z" fill="#FFF4E2"/><path d="M344.2 230.52L411.4 196.92V222.72L371.92 333.62L411.4 313.88V339.68L344.2 373.28V347.48L383.68 236.58L344.2 256.32V230.52Z" fill="#FFF4E2"/></symbol></svg>
|
||||||
|
<form id="login" onsubmit="doLogin();return false"><div class="brand"><svg><use href="#logo"/></svg><span>inventor<b>EZ</b></span></div>
|
||||||
|
<input id="lg" placeholder="Логин" autocomplete="username"><input id="pw" type="password" placeholder="Пароль" autocomplete="current-password">
|
||||||
|
<div id="lerr"></div><button class="p">Войти</button></form>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<div class="ov" id="ov" onmousedown="if(event.target==this)closeModal()"><div class="mc" id="mc"></div></div>
|
||||||
|
<div class="ov" id="dv" style="z-index:40" onmousedown="if(event.target==this)dlgClose(null)"><div class="mc" id="dc"></div></div>
|
||||||
|
<div id="app" style="display:none"><div id="top"></div><div class="wrap"><main id="m"></main><aside id="s"></aside></div></div>
|
||||||
|
<script>
|
||||||
|
const FONT={h:27,g:{" ":"8|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","!":"10|AAAAAAAAB4HgeB4HgeB4HgeB4AAAB4HgeB4AAAAAAAAAAA","\"":"11|AAAAAAAAAHMOYcw5hzDmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","#":"18|AAAAAAAAAAAAAAAAc4Ac4AY4AYwA4wH/+H/+AxgBxgBzgf/8f/8DjADnADHADGAAAAAAAAAAAAAAAAAAAA","$":"15|AAAAAAAAAAAAADAAYAfwH/h7MOYB7AP8B/4H/gP+AbwDeMbx/8H+ADAAYADAAAAAAAAA","%":"22|AAAAAAAAAAAAAAAAAAB4DgPwMBzhwHOGAYYwBznAHOYAPzngec/ABnOAOc4AxxgGHOA4c4DA/AcB4AAAAAAAAAAAAAAAAAAAAAAA","&":"19|AAAAAAAAAAAAAAAAB+AD/gB4QA8AAeAAPgAP4eP+OHvnHj3jw/x4fweHwPj8D//AfnwAAAAAAAAAAAAAAAAAAAA","'":"7|AAAAAAcOHDhw4AAAAAAAAAAAAAAAAAAA","(":"10|AAAAAAAHA8DgeBwHA8DwPA8DwPA8DwHAcB4DgPAcAAAAAA",")":"10|AAAAAAA4DwHAeA4DwPA8DwPA8DwPA8DwOB4HA8DgAAAAAA","*":"12|AAAAAAAAAABgBgRmf+H4H4f+RmBgBgAAAAAAAAAAAAAAAAAAAAAAAAA","+":"18|AAAAAAAAAAAAAAAAAAAAAAAAAOAAOAAOAAOAAOAP/8P/8P/8AOAAOAAOAAOAAOAAAAAAAAAAAAAAAAAAAA",",":"8|AAAAAAAAAAAAAAAAAAAAAAA8PDw8ODhwAAAA","-":"9|AAAAAAAAAAAAAAAAAAAD+fz+AAAAAAAAAAAAAAAAAA",".":"8|AAAAAAAAAAAAAAAAAAAAAAA8PDw8AAAAAAAA","/":"9|AAAAAAAAOBwMDgcDAYHAwGBwMBgcDgYHA4AAAAAAAA","0":"15|AAAAAAAAAAAAAPgH/B48PDj4eeDzweeDzweeDzwefDx4cPHg/4B8AAAAAAAAAAAAAAAA","1":"15|AAAAAAAAAAAAAfgP8B3gA8AHgA8AHgA8AHgA8AHgA8AHgA8A/+H/wAAAAAAAAAAAAAAA","2":"15|AAAAAAAAAAAAA/gP/Bx8IHgA8AHgA8APgB4AeAHgD4A+APgB/8P/gAAAAAAAAAAAAAAA","3":"15|AAAAAAAAAAAAAfwP/Bh8AHgA8AHgB4D+Af4APgA8AHwA8YPj/4H8AAAAAAAAAAAAAAAA","4":"15|AAAAAAAAAAAAAD4A/AH4B/Ad4HvA54OPDx4cPD/+f/wB4APAB4APAAAAAAAAAAAAAAAA","5":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB/wP/Bh8AHwA+AHwA+MPh/4H+AAAAAAAAAAAAAAAA","6":"15|AAAAAAAAAAAAAH4D/g8EPAB4AP+D/8fnj4efDx4ePDx4eHngf4B+AAAAAAAAAAAAAAAA","7":"15|AAAAAAAAAAAAD/+f/wA+APgB8APAD4AeAHwA8AHgB4APAD4AeAHwAAAAAAAAAAAAAAAA","8":"15|AAAAAAAAAAAAAfwH/h48PHx4ePHh48D/A/4PHj4eeDz4ePHx/8D+AAAAAAAAAAAAAAAA","9":"15|AAAAAAAAAAAAAfgH/B58fHjw+eHzw+fHx5+H/wf+ADgA8MPB/wH4AAAAAAAAAAAAAAAA",":":"9|AAAAAAAAAAAAAB4PB4PAAAAAAB4PB4PAAAAAAAAAAA",";":"9|AAAAAAAAAAAAAB4PB4PAAAAAAB4PB4PBwODgAAAAAA","<":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAMAA8AH8A/wH+APwAPgAH8AA/wAH8AA8AAMAAAAAAAAAAAAAAAAAAAAAA","=":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/8P/8P/8AAAAAAP/8P/8P/8AAAAAAAAAAAAAAAAAAAAAAAAAAAA",">":"18|AAAAAAAAAAAAAAAAAAAAAAAAMAAPgAP8AB/gAP4AB8AB8AP4B/gP8APgAMAAAAAAAAAAAAAAAAAAAAAAAA","?":"13|AAAAAAAAAAAPwf+MPAHgDwD4D4D4B4A8AAAAAHgDwB4A8AAAAAAAAAAAAAA","@":"22|AAAAAAAAAAAAAAAAAAAD+AA/+AHg8A4A4GABw4dzDH/MMc8xxhzHOHMcYcwxz3DH/4OHeAYAAA4BgD4eAD/wAD8AAAAAAAAAAAAA","A":"17|AAAAAAAAAAAAAAAfAB/AD+AH8Af8A94B7wHjwPHg+Ph//D/+PAeeA88B7wB4AAAAAAAAAAAAAAAAAA","B":"17|AAAAAAAAAAAAAAH/gP/weHg8Ph4fDw8Hh4P/gf/g8Ph4PDweHg8PD4f/g/+AAAAAAAAAAAAAAAAAAA","C":"16|AAAAAAAAAAAAAAP8D/4fDj4CPAB8AHwAeAB4AHwAfAA8AD4CHw4P/gP8AAAAAAAAAAAAAAAA","D":"18|AAAAAAAAAAAAAAAP/AP/wPD4PB8PA8PA+PAePAePAePAePA+PA8PB8PD4P/wP/AAAAAAAAAAAAAAAAAAAA","E":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB/8P/gAAAAAAAAAAAAAAA","F":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB4APAAAAAAAAAAAAAAAAA","G":"18|AAAAAAAAAAAAAAAA/4D/8HwcPgEPAAfAAfAAeD+eD+fA+fA+PA+Pg+Hw+D/+A/4AAAAAAAAAAAAAAAAAAA","H":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8PA8P/8P/8PA8PA8PA8PA8PA8PA8PA8AAAAAAAAAAAAAAAAAAA","I":"8|AAAAAAA8PDw8PDw8PDw8PDw8PDw8AAAAAAAA","J":"8|AAAAAAA8PDw8PDw8PDw8PDw8PDw8PHz44AAA","K":"18|AAAAAAAAAAAAAAAPA8PB4PDwPPgPfAP+AP8AP8AP8AP+APfAPPgPHwPD4PB8PA+AAAAAAAAAAAAAAAAAAA","L":"14|AAAAAAAAAAAA8APADwA8APADwA8APADwA8APADwA8APAD/4/+AAAAAAAAAAAAAAA","M":"22|AAAAAAAAAAAAAAAAAAD8D8PwPw/B/D+H8P4fw/z/D3O8Pf7w8/PDz88PHjw8ePDx48PADw8APDwA8AAAAAAAAAAAAAAAAAAAAAAA","N":"18|AAAAAAAAAAAAAAAPg8Pw8Pw8P48P48P88Pc8Pe8PO8PP8PH8PH8PD8PD8PB8PB8AAAAAAAAAAAAAAAAAAA","O":"19|AAAAAAAAAAAAAAAAB/AD/4D4+D4Ph4Dx8B4+A+eAfPAPnwHz4Dw8B4fB8Hx8B/8AP4AAAAAAAAAAAAAAAAAAAAA","P":"16|AAAAAAAAAAAAAD/wP/w8PjwePB48HjwePD4//D/wPAA8ADwAPAA8ADwAAAAAAAAAAAAAAAAA","Q":"19|AAAAAAAAAAAAAAAAB/AD/4D4+D4Ph4Dx8B4+A+eAfPAPnwHz4Dw8B4fB8Hx8B/8AP8AAeAAHgAB4AAAAAAAAAAA","R":"17|AAAAAAAAAAAAAAH/gP/gePg8PB4eDw8Hh4PHwf/A/+B4+Dw8Hh8PB4eD48DwAAAAAAAAAAAAAAAAAA","S":"16|AAAAAAAAAAAAAA/4H/g8ODwIOAA8AD/AP/Af/AP8AHwAPCA8OHw/+B/gAAAAAAAAAAAAAAAA","T":"15|AAAAAAAAAAAAH////4PgB8APgB8APgB8APgB8APgB8APgB8APgB8AAAAAAAAAAAAAAAA","U":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8Ph8Hj4D/wB/gAAAAAAAAAAAAAAAAAAA","V":"17|AAAAAAAAAAAAAAfAPeA88B58Hx4PDweHx8HjwPHgPeAe8A/4A/gB/AD+AD4AAAAAAAAAAAAAAAAAAA","W":"24|AAAAAAAAAAAAAAAAAAAAeDwfeD4eeH4efH4ePH4ePH48PHc8POc8Huc8Hud4H+P4H8P4D8P4D8PwD8HwD4HwAAAAAAAAAAAAAAAAAAAAAAAA","X":"17|AAAAAAAAAAAAAAPAePB4fHwePAe8A/4A/gA+AD8AH8Af8B98Dx4Pj4+D54DwAAAAAAAAAAAAAAAAAA","Y":"17|AAAAAAAAAAAAAAfA+eB4+Hw+fA88B/4B/gB+AD8ADwAHgAPAAeAA8AB4ADwAAAAAAAAAAAAAAAAAAA","Z":"16|AAAAAAAAAAAAAH/+f/4AfgD8APgB+APwB+AHwA/AH4A/AD4AfgB//n/+AAAAAAAAAAAAAAAA","[":"10|AAAAAAA/j+PA8DwPA8DwPA8DwPA8DwPA8DwPA/j+AAAAAA","\\":"9|AAAAAAAHA4DAcDgMBgOAwGA4DAYDgcBgOBwAAAAAAA","]":"10|AAAAAAB/H8DwPA8DwPA8DwPA8DwPA8DwPA8Dx/H8AAAAAA","^":"18|AAAAAAAAAAAAAAAAeAA/AB/gBzwDh4HAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","_":"11|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///8AAA","`":"11|AAAAADgDgDgDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
|
||||||
|
const QR = (() => {
|
||||||
|
const EC = {1: [16, 10, 1], 2: [28, 16, 1], 3: [44, 26, 1], 4: [32, 18, 2], 5: [43, 24, 2], 6: [27, 16, 4]};
|
||||||
|
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 256) x ^= 0x11d; }
|
||||||
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||||
|
const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
|
||||||
|
function gen(n) {
|
||||||
|
let g = [1];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ng = new Array(g.length + 1).fill(0);
|
||||||
|
for (let j = 0; j < g.length; j++) { ng[j] ^= g[j]; ng[j + 1] ^= mul(g[j], EXP[i]); }
|
||||||
|
g = ng;
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function rem(data, g) {
|
||||||
|
const n = g.length - 1, r = new Array(n).fill(0);
|
||||||
|
for (const b of data) {
|
||||||
|
const f = b ^ r.shift(); r.push(0);
|
||||||
|
for (let i = 0; i < n; i++) r[i] ^= mul(g[i + 1], f);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function utf8(s) { return Array.from(new TextEncoder().encode(s)); }
|
||||||
|
|
||||||
|
function codewords(bytes, v) {
|
||||||
|
const [dpb, epb, nb] = EC[v], cap = dpb * nb, bits = [];
|
||||||
|
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
||||||
|
put(4, 4); put(bytes.length, 8); bytes.forEach(b => put(b, 8));
|
||||||
|
put(0, Math.min(4, cap * 8 - bits.length));
|
||||||
|
while (bits.length % 8) bits.push(0);
|
||||||
|
const data = [];
|
||||||
|
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
|
||||||
|
for (let p = 0; data.length < cap; p ^= 1) data.push(p ? 0x11 : 0xec);
|
||||||
|
const g = gen(epb), blocks = [], ecs = [];
|
||||||
|
for (let b = 0; b < nb; b++) { const d = data.slice(b * dpb, (b + 1) * dpb); blocks.push(d); ecs.push(rem(d, g)); }
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < dpb; i++) blocks.forEach(b => out.push(b[i]));
|
||||||
|
for (let i = 0; i < epb; i++) ecs.forEach(e => out.push(e[i]));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(text) {
|
||||||
|
const bytes = utf8(text);
|
||||||
|
let v = 0;
|
||||||
|
for (let k = 1; k <= 6; k++) if (bytes.length <= EC[k][0] * EC[k][2] - 2) { v = k; break; }
|
||||||
|
if (!v) return null;
|
||||||
|
const size = 17 + 4 * v;
|
||||||
|
const mod = Array.from({length: size}, () => new Array(size).fill(false));
|
||||||
|
const fn = Array.from({length: size}, () => new Array(size).fill(false));
|
||||||
|
const set = (x, y, d) => { mod[y][x] = d; fn[y][x] = true; };
|
||||||
|
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||||
|
const finder = (cx, cy) => {
|
||||||
|
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
||||||
|
const x = cx + dx, y = cy + dy;
|
||||||
|
if (x < 0 || y < 0 || x >= size || y >= size) continue;
|
||||||
|
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
set(x, y, d !== 2 && d !== 4);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
finder(3, 3); finder(size - 4, 3); finder(3, size - 4);
|
||||||
|
if (v >= 2) {
|
||||||
|
const c = size - 7;
|
||||||
|
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
||||||
|
}
|
||||||
|
const drawFormat = mask => {
|
||||||
|
const d = mask; let rr = d;
|
||||||
|
for (let i = 0; i < 10; i++) rr = (rr << 1) ^ ((rr >>> 9) * 0x537);
|
||||||
|
const bits = ((d << 10) | rr) ^ 0x5412, bit = i => ((bits >>> i) & 1) !== 0;
|
||||||
|
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
||||||
|
set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
|
||||||
|
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
||||||
|
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
||||||
|
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
||||||
|
set(8, size - 8, true);
|
||||||
|
};
|
||||||
|
drawFormat(0);
|
||||||
|
const cw = codewords(bytes, v);
|
||||||
|
let i = 0;
|
||||||
|
for (let right = size - 1; right >= 1; right -= 2) {
|
||||||
|
if (right === 6) right = 5;
|
||||||
|
for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
|
||||||
|
const x = right - j, up = ((right + 1) & 2) === 0, y = up ? size - 1 - vert : vert;
|
||||||
|
if (!fn[y][x] && i < cw.length * 8) { mod[y][x] = ((cw[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0; i++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const masks = [
|
||||||
|
(x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x, y) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
|
||||||
|
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => (x * y) % 2 + (x * y) % 3 === 0,
|
||||||
|
(x, y) => ((x * y) % 2 + (x * y) % 3) % 2 === 0, (x, y) => ((x + y) % 2 + (x * y) % 3) % 2 === 0];
|
||||||
|
const apply = m => { for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && masks[m](x, y)) mod[y][x] = !mod[y][x]; };
|
||||||
|
const penalty = () => {
|
||||||
|
let p = 0, dark = 0;
|
||||||
|
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (mod[y][x]) dark++;
|
||||||
|
for (let t = 0; t < 2; t++) {
|
||||||
|
for (let a = 0; a < size; a++) {
|
||||||
|
const line = []; for (let b = 0; b < size; b++) line.push(t ? mod[b][a] : mod[a][b]);
|
||||||
|
let run = 1;
|
||||||
|
for (let b = 1; b <= size; b++) {
|
||||||
|
if (b < size && line[b] === line[b - 1]) run++; else { if (run >= 5) p += 3 + run - 5; run = 1; }
|
||||||
|
}
|
||||||
|
const s = line.map(c => (c ? '1' : '0')).join('');
|
||||||
|
for (const pat of ['00001011101', '10111010000']) { let k = -1; while ((k = s.indexOf(pat, k + 1)) >= 0) p += 40; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
|
||||||
|
const c = mod[y][x]; if (c === mod[y][x + 1] && c === mod[y + 1][x] && c === mod[y + 1][x + 1]) p += 3;
|
||||||
|
}
|
||||||
|
const total = size * size, k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
||||||
|
return p + Math.max(0, k) * 10;
|
||||||
|
};
|
||||||
|
let best = 0, bp = Infinity;
|
||||||
|
for (let m = 0; m < 8; m++) { apply(m); drawFormat(m); const p = penalty(); if (p < bp) { bp = p; best = m; } apply(m); }
|
||||||
|
apply(best); drawFormat(best);
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
|
||||||
|
const crc32 = b => { let c = 0xffffffff; for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
||||||
|
const be32 = v => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
|
||||||
|
function chunk(type, data) {
|
||||||
|
const t = Array.from(type, ch => ch.charCodeAt(0)), body = new Uint8Array(t.length + data.length);
|
||||||
|
body.set(t); body.set(data, 4);
|
||||||
|
return [...be32(data.length), ...body, ...be32(crc32(body))];
|
||||||
|
}
|
||||||
|
const GL = {};
|
||||||
|
function glyph(ch) {
|
||||||
|
if (GL[ch]) return GL[ch];
|
||||||
|
let src = FONT.g[ch] || FONT.g['?'], [w, b64] = src.split('|'); w = +w;
|
||||||
|
const bin = atob(b64), bits = [];
|
||||||
|
for (let i = 0; i < bin.length; i++) for (let k = 7; k >= 0; k--) bits.push((bin.charCodeAt(i) >> k) & 1);
|
||||||
|
return (GL[ch] = {w, bits});
|
||||||
|
}
|
||||||
|
const tw = s => Array.from(s).reduce((a, ch) => a + glyph(ch).w + 1, -1);
|
||||||
|
function wrap(text, maxW, maxLines) {
|
||||||
|
const words = text.replace(/\s+/g, ' ').trim().split(' '), lines = []; let cur = '';
|
||||||
|
const push = () => { if (cur) lines.push(cur); cur = ''; };
|
||||||
|
for (let w of words) {
|
||||||
|
while (tw(w) > maxW) {
|
||||||
|
let k = 1; while (k < w.length && tw(w.slice(0, k + 1)) <= maxW) k++;
|
||||||
|
push(); lines.push(w.slice(0, k)); w = w.slice(k);
|
||||||
|
}
|
||||||
|
if (!cur) cur = w; else if (tw(cur + ' ' + w) <= maxW) cur += ' ' + w; else { push(); cur = w; }
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
lines.length = maxLines; let l = lines[maxLines - 1];
|
||||||
|
while (l.length > 1 && tw(l + '…') > maxW) l = l.slice(0, -1);
|
||||||
|
lines[maxLines - 1] = l + '…';
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
function render(m, scale, border, label) {
|
||||||
|
const n = m.length, QW = (n + border * 2) * scale;
|
||||||
|
let lines = [], lh = 0, textH = 0;
|
||||||
|
if (label) { lines = wrap(label, QW - 24, 3); lh = FONT.h + 4; textH = lines.length * lh + 14; }
|
||||||
|
const W = QW, H = QW + textH, pix = new Uint8Array(W * H);
|
||||||
|
for (let y = 0; y < QW; y++) for (let x = 0; x < QW; x++) {
|
||||||
|
const mx = Math.floor(x / scale) - border, my = Math.floor(y / scale) - border;
|
||||||
|
if (mx >= 0 && my >= 0 && mx < n && my < n && m[my][mx]) pix[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
lines.forEach((ln, li) => {
|
||||||
|
let x0 = Math.max(0, Math.round((W - tw(ln)) / 2)); const y0 = QW + 4 + li * lh;
|
||||||
|
for (const ch of ln) {
|
||||||
|
const g = glyph(ch);
|
||||||
|
for (let y = 0; y < FONT.h; y++) for (let x = 0; x < g.w; x++) if (g.bits[y * g.w + x] && x0 + x < W) pix[(y0 + y) * W + x0 + x] = 1;
|
||||||
|
x0 += g.w + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {W, H, pix};
|
||||||
|
}
|
||||||
|
function encodePng(W, H, pix) {
|
||||||
|
const rb = Math.ceil(W / 8), raw = new Uint8Array((rb + 1) * H);
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const o = y * (rb + 1); raw[o] = 0;
|
||||||
|
for (let x = 0; x < W; x++) if (!pix[y * W + x]) raw[o + 1 + (x >> 3)] |= 0x80 >> (x & 7);
|
||||||
|
}
|
||||||
|
const z = [0x78, 0x01];
|
||||||
|
for (let p = 0; p < raw.length; p += 65535) {
|
||||||
|
const len = Math.min(65535, raw.length - p), last = p + len >= raw.length ? 1 : 0;
|
||||||
|
z.push(last, len & 255, len >> 8, ~len & 255, (~len >> 8) & 255);
|
||||||
|
for (let k = 0; k < len; k++) z.push(raw[p + k]);
|
||||||
|
}
|
||||||
|
let a = 1, b = 0; for (let i = 0; i < raw.length; i++) { a = (a + raw[i]) % 65521; b = (b + a) % 65521; }
|
||||||
|
z.push(...be32(((b << 16) | a) >>> 0));
|
||||||
|
return Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10,
|
||||||
|
...chunk('IHDR', Uint8Array.from([...be32(W), ...be32(H), 1, 0, 0, 0, 0])),
|
||||||
|
...chunk('IDAT', Uint8Array.from(z)),
|
||||||
|
...chunk('IEND', new Uint8Array(0))]);
|
||||||
|
}
|
||||||
|
function make(text, o = {}) {
|
||||||
|
const m = build(text); if (!m) return null;
|
||||||
|
const {W, H, pix} = render(m, o.scale || 8, 4, o.label || '');
|
||||||
|
const bytes = encodePng(W, H, pix);
|
||||||
|
let s = ''; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
||||||
|
return {url: 'data:image/png;base64,' + btoa(s), w: W, h: H};
|
||||||
|
}
|
||||||
|
const png = (text, o) => { const r = make(text, o); return r ? r.url : ''; };
|
||||||
|
return {build, make, png};
|
||||||
|
})();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
let MUST=false;
|
||||||
|
let S={lists:[],cur:null},REF={dicts:[],columns:[]},STD=[],LT=[],COLS={},me=null,CSRF='',editing=null,sel=new Set();
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const today=()=>new Date().toLocaleDateString('ru-RU');
|
||||||
|
const uid=()=>'l'+Math.random().toString(36).slice(2,11);
|
||||||
|
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
const cur=()=>S.lists.find(l=>l.id==S.cur);
|
||||||
|
const TYPES={text:'Текст',number:'Число',date:'Дата',select:'Выпадающий список',check:'Флажок (да/нет)'};
|
||||||
|
const dict=id=>REF.dicts.find(d=>d.id==id);
|
||||||
|
const dvals=id=>(dict(id)||{values:[]}).values;
|
||||||
|
const fd=s=>s?s.split('-').reverse().join('.'):'';
|
||||||
|
const show=(c,v)=>c.type=='date'?fd(v):c.type=='check'?(v?'Да':''):v||'';
|
||||||
|
|
||||||
|
async function api(a,d={}){
|
||||||
|
const r=await fetch('api.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF':CSRF},body:JSON.stringify({a,...d})});
|
||||||
|
let j=null;try{j=await r.json()}catch(e){}
|
||||||
|
if(r.status==401&&a!='login'&&a!='me'){location.reload();throw new Error('auth')}
|
||||||
|
if(!j)throw new Error('Сервер вернул не JSON (код '+r.status+'). Проверьте версию PHP и права на папку data.');
|
||||||
|
if(!r.ok)throw new Error(j.error||'Ошибка '+r.status);
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
const fail=e=>{if(e.message!='auth')return say(e.message)};
|
||||||
|
function toast(t,fn){const e=$('toast');e.textContent=t;e.style.display='block';e.onclick=fn?()=>{e.style.display='none';fn()}:null;clearTimeout(toast.t);toast.t=setTimeout(()=>e.style.display='none',fn?6000:2500)}
|
||||||
|
function openModal(h){$('mc').innerHTML=h;$('ov').style.display='flex'}
|
||||||
|
function closeModal(){$('ov').style.display='none';$('mc').innerHTML=''}
|
||||||
|
let dlgRes=null;
|
||||||
|
function dlgOpen(html,done){$('dc').innerHTML=html;$('dv').style.display='flex';dlgRes=done;const b=$('dc').querySelector('input,button.p');if(b)b.focus()}
|
||||||
|
function dlgClose(v){$('dv').style.display='none';$('dc').innerHTML='';const r=dlgRes;dlgRes=null;if(r)r(v)}
|
||||||
|
const say=m=>new Promise(r=>dlgOpen(`<div>${esc(m).replace(/\n/g,'<br>')}</div><div class="row"><button class="p" onclick="dlgClose(true)">ОК</button></div>`,r));
|
||||||
|
const ask=(m,ok='Удалить')=>new Promise(r=>dlgOpen(`<div>${esc(m)}</div><div class="row"><button onclick="dlgClose(false)">Отмена</button><button class="p" onclick="dlgClose(true)">${esc(ok)}</button></div>`,v=>r(v===true)));
|
||||||
|
document.addEventListener('keydown',e=>{if(e.key=='Escape'){if($('dv').style.display=='flex')dlgClose(null);else closeModal()}});
|
||||||
|
|
||||||
|
function applyInfo(j){me=j.user;CSRF=j.csrf;MUST=!!j.mustChange}
|
||||||
|
async function doLogin(){
|
||||||
|
try{const j=await api('login',{login:$('lg').value,password:$('pw').value});applyInfo(j);await showApp()}
|
||||||
|
catch(e){$('lerr').textContent=e.message;$('pw').value=''}
|
||||||
|
}
|
||||||
|
async function chPw(){
|
||||||
|
const v=await new Promise(r=>dlgOpen(`<h2>Смена пароля</h2><label>Текущий пароль<input id="pw_o" type="password" autocomplete="current-password"></label>
|
||||||
|
<label>Новый пароль (не короче 8 символов)<input id="pw_n" type="password" autocomplete="new-password" onkeydown="if(event.key=='Enter')dlgClose({o:$('pw_o').value,n:$('pw_n').value})"></label>
|
||||||
|
<div class="row"><button onclick="dlgClose(null)">Отмена</button><button class="p" onclick="dlgClose({o:$('pw_o').value,n:$('pw_n').value})">Сменить</button></div>`,r));
|
||||||
|
if(!v)return;
|
||||||
|
try{await api('passwd',{old:v.o,new:v.n});say('Пароль изменён')}catch(e){fail(e)}
|
||||||
|
}
|
||||||
|
async function logout(){try{await api('logout')}catch(e){}location.reload()}
|
||||||
|
function setRef(j){REF=j.ref;if(j.std)STD=j.std;LT=(dict('ltypes')||{values:[]}).values;COLS={};STD.concat(REF.columns).forEach(c=>COLS[c.id]=c)}
|
||||||
|
async function load(){const j=await api('state');setRef(j);if(j.lists)S.lists=j.lists;if(S.cur&&!cur())S.cur=null}
|
||||||
|
const BRAND='<span class="brand"><svg><use href="#logo"/></svg><span>inventor<b>EZ</b></span></span>';
|
||||||
|
function drawTop(){
|
||||||
|
$('top').innerHTML=BRAND+`<span class="sp"></span><span class="who">Вы вошли как <b>${esc(me.login)}</b></span>`+(MUST?'':`<button onclick="chPw()">Сменить пароль</button>`)+`<button onclick="logout()">Выйти</button>`;
|
||||||
|
}
|
||||||
|
function renderForce(){
|
||||||
|
$('s').style.display='none';
|
||||||
|
$('m').innerHTML=`<div class="card" style="max-width:440px;margin:6vh auto 0"><h2 style="margin-bottom:6px">Смените пароль</h2>
|
||||||
|
<p class="note">Сейчас действует пароль по умолчанию. Придумайте свой, не короче 8 символов: без этого работа с системой недоступна.</p>
|
||||||
|
<label class="fld">Текущий пароль<input id="f_o" type="password" autocomplete="current-password"></label>
|
||||||
|
<label class="fld">Новый пароль<input id="f_n" type="password" autocomplete="new-password"></label>
|
||||||
|
<label class="fld">Повторите новый пароль<input id="f_r" type="password" autocomplete="new-password" onkeydown="if(event.key=='Enter')forcePw()"></label>
|
||||||
|
<button class="p" onclick="forcePw()">Сменить пароль</button></div>`;
|
||||||
|
$('f_o').focus();
|
||||||
|
}
|
||||||
|
async function forcePw(){
|
||||||
|
if($('f_n').value!==$('f_r').value)return say('Новый пароль и повтор не совпадают');
|
||||||
|
try{await api('passwd',{old:$('f_o').value,new:$('f_n').value});MUST=false;drawTop();await enter()}catch(e){fail(e)}
|
||||||
|
}
|
||||||
|
async function enter(){
|
||||||
|
await load();
|
||||||
|
if(me.role=='admin'){$('s').style.display='none';adminTab='dicts';renderAdmin()}
|
||||||
|
else{hashPick();$('s').style.display='';render()}
|
||||||
|
}
|
||||||
|
async function showApp(){
|
||||||
|
$('login').style.display='none';$('app').style.display='';
|
||||||
|
drawTop();
|
||||||
|
setInterval(()=>api('ping').catch(()=>{}),30000);
|
||||||
|
if(MUST)return renderForce();
|
||||||
|
await enter();
|
||||||
|
}
|
||||||
|
|
||||||
|
let adminTab='dicts',usersTimer=null;
|
||||||
|
const refCall=async(a,p)=>{try{setRef(await api(a,p))}catch(e){await fail(e)}renderAdmin()};
|
||||||
|
function renderAdmin(){
|
||||||
|
clearInterval(usersTimer);
|
||||||
|
const tabs=[['dicts','Справочники'],['cols','Колонки'],['users','Пользователи']];
|
||||||
|
$('m').innerHTML=`<h1>Администрирование</h1><div class="tabs">`+tabs.map(([k,t])=>`<button class="${adminTab==k?'on':''}" onclick="adminTab='${k}';renderAdmin()">${t}</button>`).join('')+`</div><div id="at"></div>`;
|
||||||
|
({dicts:admDicts,cols:admCols,users:admUsers})[adminTab]();
|
||||||
|
}
|
||||||
|
let openD=new Set();try{openD=new Set(JSON.parse(localStorage.getItem('inv_open_dicts')||'[]'))}catch(e){}
|
||||||
|
const saveOpen=()=>{try{localStorage.setItem('inv_open_dicts',JSON.stringify([...openD]))}catch(e){}};
|
||||||
|
function togDict(id){openD.has(id)?openD.delete(id):openD.add(id);saveOpen();admDicts()}
|
||||||
|
function allDicts(open){openD=new Set(open?REF.dicts.map(d=>d.id):[]);saveOpen();admDicts()}
|
||||||
|
function admDicts(){
|
||||||
|
const core=id=>id=='cats'||id=='locs'||id=='ltypes';
|
||||||
|
let h=`<p class="note">Справочники — наборы значений для выпадающих списков. Из них админ собирает колонки (вкладка «Колонки»). «Типы списков» (Коробка, Мешок, Полка…) — особый справочник: его значения пользователи выбирают для самих списков.</p>`;
|
||||||
|
h+=`<div class="bar" style="margin:0 0 10px"><button onclick="allDicts(true)">Развернуть все</button><button onclick="allDicts(false)">Свернуть все</button></div>`;
|
||||||
|
h+=REF.dicts.map((d,di)=>{const op=openD.has(d.id);return `<div class="card"><div class="dh"><button class="i" title="${op?'Свернуть':'Развернуть'}" style="font-size:16px;width:32px" onclick="togDict('${d.id}')">${op?'▾':'▸'}</button>
|
||||||
|
<input value="${esc(d.name)}" onchange="refCall('dict_op',{op:'rename_dict',id:'${d.id}',value:this.value})"><span class="badge">${d.values.length} знач.</span>
|
||||||
|
${core(d.id)?'<span class="badge">стандартный</span>':`<button class="i" title="Удалить справочник" onclick="delDict(${di})">🗑</button>`}</div>`+
|
||||||
|
(op?`<table>`+d.values.map((v,vi)=>`<tr><td><input value="${esc(v)}" onchange="valOp('rename_value',${di},${vi},this.value)"></td>
|
||||||
|
<td class="a"><button class="i" title="Удалить" onclick="valOp('delete_value',${di},${vi})">🗑</button></td></tr>`).join('')+
|
||||||
|
`<tr class="ed"><td><input id="av_${d.id}" placeholder="Новое значение" onkeydown="if(event.key=='Enter')addVal('${d.id}')"></td><td class="a"><button class="p" onclick="addVal('${d.id}')">+</button></td></tr></table>`:'')+`</div>`}).join('');
|
||||||
|
h+=`<div class="card"><div class="dh"><input id="nd" placeholder="Название нового справочника" onkeydown="if(event.key=='Enter')addDict()"><button class="p" onclick="addDict()">+ Справочник</button></div></div>`;
|
||||||
|
$('at').innerHTML=h;
|
||||||
|
}
|
||||||
|
const valOp=(op,di,vi,value)=>refCall('dict_op',{op,id:REF.dicts[di].id,old:REF.dicts[di].values[vi],value});
|
||||||
|
const delDict=async di=>{if(await ask('Удалить справочник «'+REF.dicts[di].name+'»?'))refCall('dict_op',{op:'delete_dict',id:REF.dicts[di].id})};
|
||||||
|
const delCol=async id=>{if(await ask('Удалить колонку?'))refCall('col_op',{op:'delete',id})};
|
||||||
|
async function addVal(id){const v=$('av_'+id).value;await refCall('dict_op',{op:'add_value',id,value:v});const e=$('av_'+id);if(e)e.focus()}
|
||||||
|
async function addDict(){const before=new Set(REF.dicts.map(d=>d.id));await refCall('dict_op',{op:'add_dict',value:$('nd').value});REF.dicts.filter(d=>!before.has(d.id)).forEach(d=>openD.add(d.id));saveOpen();admDicts()}
|
||||||
|
function admCols(){
|
||||||
|
const typeOpts=t=>Object.entries(TYPES).map(([k,v])=>`<option value="${k}"${k==t?' selected':''}>${v}</option>`).join('');
|
||||||
|
const dictOpts=d=>`<option value="">— справочник —</option>`+REF.dicts.filter(x=>x.id!='ltypes').map(x=>`<option value="${x.id}"${x.id==d?' selected':''}>${esc(x.name)}</option>`).join('');
|
||||||
|
$('at').innerHTML=`<p class="note">Колонки, которые пользователи смогут выбирать при создании «пользовательского» списка. Тип и справочник нельзя менять, пока колонка используется в списках.</p>
|
||||||
|
<div class="card"><table><tr><th>Название</th><th>Тип ввода</th><th>Справочник</th><th></th></tr>`+
|
||||||
|
REF.columns.map(c=>`<tr><td><input id="cn_${c.id}" value="${esc(c.name)}" onchange="colUpd('${c.id}')"></td>
|
||||||
|
<td><select id="ct_${c.id}" onchange="colType('${c.id}')">${typeOpts(c.type)}</select></td>
|
||||||
|
<td><select id="cd_${c.id}" onchange="colUpd('${c.id}')" ${c.type=='select'?'':'disabled'}>${dictOpts(c.dict)}</select></td>
|
||||||
|
<td class="a"><button class="i" title="Удалить" onclick="delCol('${c.id}')">🗑</button></td></tr>`).join('')+
|
||||||
|
(REF.columns.length?'':`<tr><td colspan=4 class="empty">Колонок пока нет — добавьте первую</td></tr>`)+
|
||||||
|
`<tr class="ed"><td><input id="nc_n" placeholder="Название новой колонки"></td><td><select id="nc_t" onchange="$('nc_d').disabled=this.value!='select'">${typeOpts('text')}</select></td>
|
||||||
|
<td><select id="nc_d" disabled>${dictOpts('')}</select></td><td class="a"><button class="p" onclick="colAdd()">+</button></td></tr></table></div>
|
||||||
|
<p class="note">Стандартные колонки (для «стандартных» списков, не редактируются): ${STD.map(c=>`<span class="badge">${esc(c.name)}</span>`).join(' ')}</p>`;
|
||||||
|
}
|
||||||
|
const colAdd=()=>refCall('col_op',{op:'add',name:$('nc_n').value,type:$('nc_t').value,dict:$('nc_d').value||null});
|
||||||
|
function colType(id){const t=$('ct_'+id).value;$('cd_'+id).disabled=t!='select';if(t=='select'&&!$('cd_'+id).value)return;colUpd(id)}
|
||||||
|
const colUpd=id=>refCall('col_op',{op:'update',id,name:$('cn_'+id).value,type:$('ct_'+id).value,dict:$('cd_'+id).value||null});
|
||||||
|
async function admUsers(){
|
||||||
|
$('at').innerHTML=`<p class="note">Все пользователи системы. «В сети» — были активны в последние 2 минуты. Список обновляется автоматически.</p>`+
|
||||||
|
`<div class="card"><h2 style="margin-bottom:10px;font-size:16px">Добавить пользователя</h2><div class="dh" style="flex-wrap:wrap">
|
||||||
|
<input id="u_l" placeholder="Логин (латиницей)" autocomplete="off" style="flex:1;min-width:150px" onkeydown="if(event.key=='Enter')$('u_p').focus()">
|
||||||
|
<input id="u_p" placeholder="Пароль (от 8 символов)" autocomplete="off" style="flex:1;min-width:150px" onkeydown="if(event.key=='Enter')userAdd()">
|
||||||
|
<button class="p" onclick="userAdd()">+ Добавить</button></div>
|
||||||
|
<p class="note" style="margin:8px 0 0">Пароль виден при вводе, чтобы вы могли передать его человеку. Сменить его пользователь сможет сам.</p></div><div id="ul"></div>`;
|
||||||
|
usersTimer=setInterval(drawUsers,15000);await drawUsers();
|
||||||
|
}
|
||||||
|
async function drawUsers(){
|
||||||
|
let u;try{u=(await api('users')).users}catch(e){return}
|
||||||
|
const el=$('ul');if(!el)return;
|
||||||
|
el.innerHTML=`<div class="card"><table><tr><th>Логин</th><th>Статус</th></tr>`+
|
||||||
|
u.map(x=>`<tr><td>${esc(x.login)}</td><td><span class="dot${x.online?' on':''}"></span>${x.online?'в сети':'не в сети'}</td></tr>`).join('')+`</table></div>`;
|
||||||
|
}
|
||||||
|
async function userAdd(){
|
||||||
|
try{await api('user_add',{login:$('u_l').value,password:$('u_p').value});$('u_l').value='';$('u_p').value='';await drawUsers();toast('Пользователь добавлен');$('u_l').focus()}
|
||||||
|
catch(e){fail(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCols=l=>l.cols.map(id=>COLS[id]).filter(Boolean);
|
||||||
|
const hasName=l=>l.cols.includes('name');
|
||||||
|
const colNames=ids=>ids.map(c=>'«'+(COLS[c]?COLS[c].name:c)+'»').join(', ');
|
||||||
|
function colDiff(src,dst){
|
||||||
|
const lost=src.cols.filter(c=>!dst.cols.includes(c)),miss=dst.cols.filter(c=>!src.cols.includes(c));
|
||||||
|
const std=dst.kind=='standard',fill=std?miss.filter(c=>c=='cat'||c=='loc'):[];
|
||||||
|
return {lost,fill,empty:miss.filter(c=>!fill.includes(c)),same:!lost.length&&!miss.length};
|
||||||
|
}
|
||||||
|
function mapV(v,dst){
|
||||||
|
const o={};dst.cols.forEach(c=>o[c]=v[c]??'');
|
||||||
|
if(dst.kind=='standard'){if(!o.name)o.name='Без названия';if(!o.cat)o.cat=dvals('cats')[0]||'';if(!o.loc)o.loc=dvals('locs')[0]||''}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
const confirmDiff=(d,verb)=>new Promise(r=>dlgOpen(`<h2>Колонки списков отличаются</h2><div>Значения сопоставляются по совпадающим колонкам.</div>`+
|
||||||
|
(d.lost.length?`<div><b>Не будут ${verb}</b> (в списке назначения нет таких колонок):<br>${esc(colNames(d.lost))}</div>`:'')+
|
||||||
|
(d.fill.length?`<div><b>Получат значение по умолчанию</b> (в исходном списке нет таких данных):<br>${esc(colNames(d.fill))}</div>`:'')+
|
||||||
|
(d.empty.length?`<div><b>Останутся пустыми</b> (в исходном списке нет таких данных):<br>${esc(colNames(d.empty))}</div>`:'')+
|
||||||
|
`<div class="row"><button onclick="dlgClose(false)">Отмена</button><button class="p" onclick="dlgClose(true)">Подтвердить</button></div>`,v=>r(v===true)));
|
||||||
|
const persist=async(l=cur())=>{try{await api('list_save',{list:l})}catch(e){fail(e);try{await load();render()}catch(_){}}};
|
||||||
|
const kindName=l=>l.kind=='custom'?'Пользовательский':'Стандартный';
|
||||||
|
function sorted(l){
|
||||||
|
const cols=listCols(l),sc=cols.find(c=>c.type=='select'),tc=cols.find(c=>c.type=='text');
|
||||||
|
const rank=it=>{if(!sc)return 0;const i=dvals(sc.dict).indexOf(it.v[sc.id]);return i<0?9999:i};
|
||||||
|
return [...l.items].sort((a,b)=>rank(a)-rank(b)||(tc?String(a.v[tc.id]||'').localeCompare(String(b.v[tc.id]||''),'ru'):0));
|
||||||
|
}
|
||||||
|
function nextName(type){
|
||||||
|
const re=new RegExp('^'+type.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+' (\\d+)$');
|
||||||
|
return type+' '+(Math.max(0,...S.lists.map(l=>+((l.name.match(re)||[])[1]||0)))+1);
|
||||||
|
}
|
||||||
|
async function pick(id){editing=null;sel.clear();try{await load()}catch(e){fail(e)}S.cur=S.lists.some(x=>x.id==id)?id:null;history.replaceState(null,'',S.cur?'#list='+S.cur:location.pathname);render()}
|
||||||
|
function hashPick(){const m=location.hash.match(/^#list=([a-z0-9]+)$/);if(m&&S.lists.some(x=>x.id==m[1])){S.cur=m[1];sel.clear();editing=null}}
|
||||||
|
window.addEventListener('hashchange',()=>{if(me&&me.role!='admin'){hashPick();render()}});
|
||||||
|
async function delList(id){
|
||||||
|
if(!await ask('Удалить список?'))return;
|
||||||
|
try{await api('list_delete',{id})}catch(e){return fail(e)}
|
||||||
|
S.lists=S.lists.filter(x=>x.id!=id);if(S.cur==id)S.cur=null;render();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nl=null;
|
||||||
|
function dlgNew(){nl={name:'Список '+today(),kind:'standard',cols:[],from:''};renderNew()}
|
||||||
|
const compatSources=()=>nl.kind=='standard'||nl.cols.includes('name')?S.lists.filter(hasName):[];
|
||||||
|
function renderNew(){
|
||||||
|
const cs=compatSources(),custom=nl.kind=='custom';
|
||||||
|
if(!cs.some(l=>l.id==nl.from))nl.from='';
|
||||||
|
openModal(`<h2>Новый список</h2>
|
||||||
|
<label>Название<input id="nl_n" value="${esc(nl.name)}" oninput="nl.name=this.value" onkeydown="if(event.key=='Enter')createList()"></label>
|
||||||
|
<label>Вид списка</label>
|
||||||
|
<label class="opt${custom?'':' on'}"><input type="radio" name="k" ${custom?'':'checked'} onchange="nl.kind='standard';renderNew()"><span>Стандартный<small>Название, кол-во, категория, расположение</small></span></label>
|
||||||
|
<label class="opt${custom?' on':''}"><input type="radio" name="k" ${custom?'checked':''} onchange="nl.kind='custom';renderNew()"><span>Пользовательский<small>Вы сами выбираете колонки: стандартные и созданные администратором</small></span></label>
|
||||||
|
${custom?`<label>Колонки таблицы</label>`+
|
||||||
|
`<div class="note" style="margin:0">Стандартные</div>`+STD.map(colChk).join('')+
|
||||||
|
(REF.columns.length?`<div class="note" style="margin:6px 0 0">Созданные администратором</div>`+REF.columns.map(colChk).join(''):''):''}
|
||||||
|
${custom&&!nl.cols.includes('name')?`<div class="note" style="margin:0">Чтобы взять предметы из другого списка, отметьте колонку «Название».</div>`:`<label>Взять предметы из существующего списка<select onchange="nl.from=this.value"><option value="">Не копировать</option>${cs.map(l=>`<option value="${l.id}"${l.id==nl.from?' selected':''}>${esc(l.name)} (${l.items.length} шт.)</option>`).join('')}</select></label>`}
|
||||||
|
<div class="row"><button onclick="closeModal()">Отмена</button><button class="p" onclick="createList()">Создать</button></div>`);
|
||||||
|
$('nl_n').focus();
|
||||||
|
}
|
||||||
|
const colChk=c=>`<label class="chk"><input type="checkbox" ${nl.cols.includes(c.id)?'checked':''} onchange="togNlCol('${c.id}',this.checked)">${esc(c.name)} <span class="tag">${TYPES[c.type]}</span></label>`;
|
||||||
|
function togNlCol(id,on){nl.cols=STD.concat(REF.columns).map(c=>c.id).filter(c=>c==id?on:nl.cols.includes(c));renderNew()}
|
||||||
|
async function createList(){
|
||||||
|
const name=nl.name.trim();
|
||||||
|
if(!name)return say('Укажите название списка');
|
||||||
|
if(nl.kind=='custom'&&!nl.cols.length)return say('Выберите хотя бы одну колонку');
|
||||||
|
const src=S.lists.find(x=>x.id==nl.from);
|
||||||
|
const l={id:uid(),name,kind:nl.kind,ltype:'',cols:nl.kind=='custom'?nl.cols:['name','qty','cat','loc'],final:false,items:[]};
|
||||||
|
if(src){
|
||||||
|
const d=colDiff(src,l);
|
||||||
|
if(!d.same&&!await confirmDiff(d,'скопированы'))return;
|
||||||
|
l.items=src.items.map(i=>({id:uid(),v:mapV(i.v,l)}));
|
||||||
|
}
|
||||||
|
S.lists.unshift(l);S.cur=l.id;editing=null;sel.clear();closeModal();render();await persist(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inp(c,v,req){
|
||||||
|
const a=`data-c="${c.id}"`;
|
||||||
|
if(c.type=='select')return `<select ${a}>${req?'':'<option value="">—</option>'}${dvals(c.dict).map(x=>`<option${x==v?' selected':''}>${esc(x)}</option>`).join('')}</select>`;
|
||||||
|
if(c.type=='check')return `<input type="checkbox" ${a} ${v?'checked':''}>`;
|
||||||
|
return `<input ${a} type="${c.type=='number'?'number':c.type=='date'?'date':'text'}" ${c.type=='number'?'step="any"':''} value="${esc(v)}" ${c.type=='text'?`placeholder="${esc(c.name)}"`:''}>`;
|
||||||
|
}
|
||||||
|
function editRow(l,it){
|
||||||
|
const std=l.kind=='standard',v=it?it.v:(std?{cat:dvals('cats')[0],loc:dvals('locs')[0]}:{});
|
||||||
|
return `<tr class="ed" onkeydown="if(event.key=='Enter'&&event.target.tagName!='SELECT')okRow()"><td class="c"></td>`+
|
||||||
|
listCols(l).map(c=>`<td>${inp(c,v[c.id]??'',std&&(c.id=='cat'||c.id=='loc'))}</td>`).join('')+
|
||||||
|
`<td class="a"><button class="p" onclick="okRow()">ОК</button><button class="i" title="Отмена" onclick="editing=null;render()">✕</button>${it?`<button class="i" title="Удалить" onclick="delItem('${it.id}')">🗑</button>`:''}</td></tr>`;
|
||||||
|
}
|
||||||
|
const focusEd=()=>{const e=document.querySelector('tr.ed [data-c]');if(e)e.focus()};
|
||||||
|
async function okRow(){
|
||||||
|
const l=cur(),v={};
|
||||||
|
document.querySelectorAll('tr.ed [data-c]').forEach(e=>v[e.dataset.c]=e.type=='checkbox'?(e.checked?'1':''):e.value.trim());
|
||||||
|
if(l.kind=='standard'){if(!v.name){document.querySelector('[data-c=name]').focus();return}}
|
||||||
|
else if(!Object.values(v).some(x=>x))return say('Заполните хотя бы одно поле');
|
||||||
|
const again=editing=='new';
|
||||||
|
if(again)l.items.push({id:uid(),v});else l.items.find(i=>i.id==editing).v=v;
|
||||||
|
editing=again?'new':null;render();if(again)focusEd();
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
async function delItem(id){
|
||||||
|
if(!await ask('Удалить предмет?'))return;
|
||||||
|
const l=cur();l.items=l.items.filter(i=>i.id!=id);sel.delete(id);editing=null;render();await persist();
|
||||||
|
}
|
||||||
|
async function finish(){const l=cur();if(!l.items.length)return say('Список пуст');l.final=true;editing=null;sel.clear();render();await persist()}
|
||||||
|
async function reopen(){cur().final=false;render();await persist()}
|
||||||
|
async function setLtype(v){const l=cur();l.ltype=v;render();await persist()}
|
||||||
|
async function rename(v){const l=cur();l.name=v.trim()||'Список '+today();render();await persist()}
|
||||||
|
|
||||||
|
function toggleMenu(e){e.stopPropagation();$('menu').classList.toggle('open')}
|
||||||
|
document.addEventListener('click',()=>{const m=$('menu');if(m)m.classList.remove('open')});
|
||||||
|
const chosen=()=>cur().items.filter(i=>sel.has(i.id));
|
||||||
|
function tog(id,on){on?sel.add(id):sel.delete(id);updAct()}
|
||||||
|
function setAll(on){document.querySelectorAll('.cb').forEach(c=>c.checked=on);sel=new Set(on?cur().items.map(i=>i.id):[]);updAct()}
|
||||||
|
function updAct(){const n=chosen().length,d=$('a_del');if(!d)return;['a_del','a_new','a_ex'].forEach(i=>$(i).disabled=!n);d.textContent='Удалить выбранные'+(n?` (${n})`:'')}
|
||||||
|
async function actDel(){
|
||||||
|
const n=chosen().length;if(!n||!await ask('Удалить выбранные предметы: '+n+'?'))return;
|
||||||
|
const l=cur();l.items=l.items.filter(i=>!sel.has(i.id));sel.clear();editing=null;render();await persist();
|
||||||
|
}
|
||||||
|
let tr=null;
|
||||||
|
function actNew(){
|
||||||
|
if(!chosen().length)return;
|
||||||
|
tr={type:LT[0],name:nextName(LT[0]),edited:false,move:false};renderTrNew();
|
||||||
|
}
|
||||||
|
function renderTrNew(){
|
||||||
|
openModal(`<h2>Занести в новый список</h2><div class="note">Предметов: ${chosen().length}</div>
|
||||||
|
<label>Название списка<input id="tr_n" value="${esc(tr.name)}" oninput="tr.name=this.value;tr.edited=true" onkeydown="if(event.key=='Enter')doTrNew()"></label>
|
||||||
|
<label>Тип списка<select onchange="tr.type=this.value;if(!tr.edited)tr.name=nextName(tr.type);renderTrNew()">${LT.map(t=>`<option${t==tr.type?' selected':''}>${esc(t)}</option>`).join('')}</select></label>
|
||||||
|
<label class="chk"><input type="checkbox" ${tr.move?'checked':''} onchange="tr.move=this.checked">Удалить из исходного списка (перенос вместо копирования)</label>
|
||||||
|
<div class="row"><button onclick="closeModal()">Отмена</button><button class="p" onclick="doTrNew()">Создать</button></div>`);
|
||||||
|
$('tr_n').focus();
|
||||||
|
}
|
||||||
|
const doTrNew=()=>{if(!tr.name.trim())return say('Укажите название списка');doTransfer({name:tr.name.trim(),ltype:tr.type},tr.move)};
|
||||||
|
function actEx(){
|
||||||
|
const l=cur();if(!chosen().length)return;
|
||||||
|
const cands=hasName(l)?S.lists.filter(x=>x.id!=l.id&&!x.final&&hasName(x)):[];
|
||||||
|
tr={to:cands[0]?cands[0].id:'',move:false};
|
||||||
|
const why=hasName(l)?'Подходящих списков нет. Нужен другой открытый список с колонкой «Название». Можно создать новый список или воспользоваться пунктом «Занести в новый список».':'В этом списке нет колонки «Название», поэтому перенос в другие списки недоступен. Используйте «Занести в новый список».';
|
||||||
|
openModal(`<h2>Занести в существующий список</h2><div class="note">Предметов: ${chosen().length}. Показаны открытые списки с колонкой «Название»; если колонки отличаются, перед переносом будет предупреждение.</div>`+
|
||||||
|
(cands.length?`<label>Список назначения<select onchange="tr.to=this.value">${cands.map(x=>`<option value="${x.id}">${esc(x.name)}${x.ltype?' · '+esc(x.ltype):''} (${x.items.length} шт.)${colDiff(l,x).same?'':' · колонки отличаются'}</option>`).join('')}</select></label>
|
||||||
|
<label class="chk"><input type="checkbox" onchange="tr.move=this.checked">Удалить из исходного списка (перенос вместо копирования)</label>`:`<div class="empty">${why}</div>`)+
|
||||||
|
`<div class="row"><button onclick="closeModal()">Отмена</button>${cands.length?`<button class="p" onclick="doExisting()">Перенести</button>`:''}</div>`);
|
||||||
|
}
|
||||||
|
async function doExisting(){
|
||||||
|
const d=colDiff(cur(),S.lists.find(x=>x.id==tr.to));
|
||||||
|
if(!d.same&&!await confirmDiff(d,'перенесены'))return;
|
||||||
|
doTransfer({existing:tr.to},tr.move,!d.same);
|
||||||
|
}
|
||||||
|
async function doTransfer(to,move,confirm=false){
|
||||||
|
const l=cur(),n=chosen().length;
|
||||||
|
try{
|
||||||
|
const j=await api('transfer',{from:l.id,ids:[...sel],to,move,confirm});
|
||||||
|
S.lists=j.lists;sel.clear();editing=null;closeModal();render();
|
||||||
|
const t=S.lists.find(x=>x.id==j.target);
|
||||||
|
toast(`Перенесено: ${n} → «${t?t.name:''}». Открыть список`,()=>pick(j.target));
|
||||||
|
}catch(e){fail(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const l=cur();
|
||||||
|
$('s').innerHTML=`<h2>Списки</h2>`+(S.lists.length?'':'<div class="tag" style="padding:4px 8px 8px">Списков пока нет</div>')+
|
||||||
|
S.lists.map(x=>`<div class="li${x.id==S.cur?' on':''}" onclick="pick('${x.id}')">
|
||||||
|
<span>${esc(x.name)}<br><span class="tag">${x.ltype?esc(x.ltype)+' · ':''}${x.kind=='custom'?'польз. · ':''}${x.items.length} шт. · ${x.final?'итог':'черновик'}</span></span>
|
||||||
|
<button class="i" title="Удалить список" onclick="event.stopPropagation();delList('${x.id}')">✕</button></div>`).join('')+
|
||||||
|
`<div style="margin-top:10px;border-top:1px solid var(--bd);padding-top:10px"><button class="p" style="width:100%" onclick="dlgNew()">+ Новый список</button></div>`;
|
||||||
|
if(!l){$('m').innerHTML='';return}
|
||||||
|
const cols=listCols(l),lts=LT.includes(l.ltype)||!l.ltype?LT:[...LT,l.ltype],
|
||||||
|
meta=`<span class="badge">${kindName(l)}</span> <label class="tag" style="display:flex;gap:4px;align-items:center">Тип:<select style="width:auto" onchange="setLtype(this.value)"><option value="">без типа</option>${lts.map(t=>`<option${t==l.ltype?' selected':''}>${esc(t)}</option>`).join('')}</select></label>`;
|
||||||
|
let h=`<div class="title">`+(l.final?`<h2 style="flex:1;font-size:18px">${esc(l.name)}</h2>${meta}`:
|
||||||
|
`<input value="${esc(l.name)}" onchange="rename(this.value)">${meta}
|
||||||
|
<div class="dd"><button onclick="toggleMenu(event)">Действия ▾</button><div class="menu" id="menu">
|
||||||
|
<button id="a_del" onclick="actDel()">Удалить выбранные</button><button id="a_new" onclick="actNew()">Занести в новый список…</button><button id="a_ex" onclick="actEx()">Занести в существующий список…</button></div></div>`)+`</div><div class="card" style="margin-bottom:0">`;
|
||||||
|
if(l.final){
|
||||||
|
h+=`<table><tr><th>№</th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}</tr>`+
|
||||||
|
sorted(l).map((i,k)=>`<tr><td>${k+1}</td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}</tr>`).join('')+`</table></div>${l.ltype?qrBlock(l):'<p class="note" style="margin-top:12px">QR-код показывается у списков с типом (Коробка, Мешок, Полка…). Выберите тип в заголовке списка, и здесь появится QR-код, он же попадёт в Word-отчёт.</p>'}<div class="bar"><button class="p" onclick="exp('xls')">Скачать XLS</button><button class="p" onclick="exp('doc')">Скачать Word</button>${l.ltype?`<button class="p" onclick="exp('qr')">Скачать QR (PNG)</button>`:''}<span class="sp"></span><button onclick="reopen()">Редактировать</button></div>`;
|
||||||
|
}else{
|
||||||
|
h+=`<table><tr><th class="c"><input type="checkbox" onchange="setAll(this.checked)"></th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}<th></th></tr>`+
|
||||||
|
l.items.map(i=>editing==i.id?editRow(l,i):`<tr><td class="c"><input class="cb" type="checkbox" ${sel.has(i.id)?'checked':''} onchange="tog('${i.id}',this.checked)"></td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}
|
||||||
|
<td class="a"><button class="i" title="Редактировать" onclick="editing='${i.id}';render();focusEd()">✏️</button></td></tr>`).join('')+
|
||||||
|
(editing=='new'?editRow(l):'')+
|
||||||
|
(!l.items.length&&!editing?`<tr><td colspan="${cols.length+2}" class="empty">Пока пусто — нажмите «+ Предмет»</td></tr>`:'')+
|
||||||
|
`</table></div><div class="bar"><button class="p" onclick="editing='new';render();focusEd()">+ Предмет</button><span class="sp"></span><button class="p" onclick="finish()">ИТОГ</button></div>`;
|
||||||
|
}
|
||||||
|
$('m').innerHTML=h;updAct();
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUrl=l=>location.origin+location.pathname+'#list='+l.id;
|
||||||
|
function qrBlock(l){
|
||||||
|
const u=listUrl(l);let q=null,err='';
|
||||||
|
try{q=QR.make(u,{label:l.name});if(!q)err='Адрес списка слишком длинный для QR-кода ('+u.length+' символов).'}catch(e){err='Не удалось построить QR-код: '+e.message}
|
||||||
|
return `<div class="card qr">${q?`<img src="${q.url}" alt="QR">`:''}${err?`<div style="color:var(--er)">${esc(err)}</div>`:''}<div><b>QR-код списка «${esc(l.name)}»</b><div class="tag">Открывает этот список после входа в систему. Название списка напечатано под кодом.</div><div class="tag" style="overflow-wrap:anywhere">${esc(u)}</div></div></div>`}
|
||||||
|
function dl(content,mime,name){
|
||||||
|
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([content],{type:mime}));
|
||||||
|
a.download=name.replace(/[\\/:*?"<>|]/g,'_');document.body.appendChild(a);a.click();a.remove();
|
||||||
|
}
|
||||||
|
function exp(ext){
|
||||||
|
const l=cur(),u=listUrl(l),cols=listCols(l);
|
||||||
|
if(ext=='qr'){
|
||||||
|
let q=null;try{q=QR.make(u,{label:l.name})}catch(e){return say('Не удалось построить QR-код: '+e.message)}
|
||||||
|
if(!q)return say('Адрес списка слишком длинный для QR-кода');
|
||||||
|
const bin=atob(q.url.split(',')[1]),b=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)b[i]=bin.charCodeAt(i);
|
||||||
|
return dl(b,'image/png','QR '+l.name+'.png');
|
||||||
|
}
|
||||||
|
const tbl=`<table border="1" cellspacing="0" cellpadding="4"><tr><th>№</th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}</tr>`+
|
||||||
|
sorted(l).map((i,k)=>`<tr><td>${k+1}</td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}</tr>`).join('')+`</table>`;
|
||||||
|
const head=`<h2>${esc(l.name)}</h2><p>${kindName(l)} список${l.ltype?' · '+esc(l.ltype):''}</p>`;
|
||||||
|
if(ext=='xls')return dl(''+`<html><head><meta charset="utf-8"></head><body>${head}${tbl}${l.ltype?`<p>Ссылка на список: <a href="${esc(u)}">${esc(u)}</a></p>`:''}</body></html>`,'application/vnd.ms-excel;charset=utf-8',l.name+'.xls');
|
||||||
|
const b64=t=>t.replace(/.{76}/g,'$&\r\n'),qm=l.ltype?QR.make(u,{label:l.name}):null,qr=qm?qm.url:'';
|
||||||
|
const html=`<html><head><meta charset="utf-8"></head><body>${head}${tbl}${l.ltype?`<p>${qr?`<img src="qr.png" width="200" height="${Math.round(200*qm.h/qm.w)}"><br>`:''}${esc(u)}</p>`:''}</body></html>`;
|
||||||
|
let m='MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary="BND"; type="text/html"\r\n\r\n--BND\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\n\r\n'+b64(btoa(unescape(encodeURIComponent(html))))+'\r\n';
|
||||||
|
if(qr)m+='--BND\r\nContent-Location: qr.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n'+b64(qr.split(',')[1])+'\r\n';
|
||||||
|
dl(m+'--BND--','application/msword',l.name+'.doc');
|
||||||
|
}
|
||||||
|
(async()=>{try{const j=await api('me');applyInfo(j);await showApp()}catch(e){$('lg').focus()}})();
|
||||||
|
</script></body></html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "inventorEZ",
|
||||||
|
"short_name": "inventorEZ",
|
||||||
|
"start_url": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f6efdf",
|
||||||
|
"theme_color": "#f6efdf",
|
||||||
|
"icons": [
|
||||||
|
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable"},
|
||||||
|
{"src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||||
|
]
|
||||||
|
}
|
||||||