641 lines
64 KiB
HTML
641 lines
64 KiB
HTML
<!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|AAAAAAAAAAAAAAAP
|
|||
|
|
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>
|