site: анализ данных — загрузка Excel/CSV файлов

This commit is contained in:
Zulfija 2026-09-01 10:04:09 +00:00
parent 8f2cfcfad7
commit 172f1148af
4 changed files with 1139 additions and 1035 deletions

View File

@ -1,4 +1,4 @@
<!-- vibe42-agents-version: v36-makexlsx-2026-07-31 --> <!-- vibe42-agents-version: v37-ktai-ds-2026-09-01 -->
# Vibe42 — учебная песочница: сайты, боты и первые приложения # Vibe42 — учебная песочница: сайты, боты и первые приложения
Workspace юзера `Zulfija`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. Workspace юзера `Zulfija`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение.
@ -23,7 +23,7 @@ Workspace юзера `Zulfija`. Это **учебная среда**, где о
| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | | Что хочет юзер | Стек (ЖЁСТКО) | Рецепт |
|----------------|---------------|--------| |----------------|---------------|--------|
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` | | Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap — вёрстка на дизайн-системе KT AI из `design-system/`. | старт с `design.md`, публикация в `pages` |
| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | | Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже |
| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | | Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже |
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | | Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже |
@ -839,6 +839,10 @@ git push origin HEAD:pages
--- ---
## 🎨 design.md ## 🎨 design.md + дизайн-система KT AI
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие. Рядом лежит `design.md` — **прочитай его перед первой строкой вёрстки**. В нём каркас `index.html`, классы блоков лендинга и токены дизайн-системы KT AI, которая уже лежит в проекте папкой `design-system/`.
Правило простое: **весь UI собирается из классов и токенов ДС**. Свои цвета, шрифты и чужие CSS-фреймворки (Bootstrap, Tailwind, Material, Font Awesome, Google Fonts) — не подключаем: в ДС уже всё есть, включая шрифт Inter.
Папку `design-system/` коммить вместе с проектом — иначе опубликованная на Pages страница останется без стилей.

249
design.md
View File

@ -1,132 +1,207 @@
<!-- vibe42-design-version: v2-recipes-2026-07-07 --> <!-- vibe42-design-version: v3-ktai-ds-2026-09-01 -->
# Design system — Vibe42 песочница # Дизайн-система KT AI — по ней собирается весь UI
Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться. В папке проекта лежит `design-system/` — это **дизайн-система KT AI**
(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`).
Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга.
## Палитра **Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу
из готовых классов ДС и наполнить её содержанием юзера.
| Token | Hex | Использование | Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз:
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика ```bash
cp -r /srv/opencode/design-system/kt-ai design-system
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
``` ```
| Уровень | Размер | Вес | line-height | ---
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
На мобиле — h1 → 36px, h2 → 28px. ## 🔒 Три правила, которые нельзя нарушать
## Лейаут 1. **Цвета и размеры — только токенами** `var(--kt-ai-…)`. Сырой hex (`#0F1218`, `#fff`) в вёрстке
запрещён: он ломает светлую/тёмную тему.
2. **Никакого Bootstrap / Tailwind / Material / Font Awesome / Google Fonts.** Шрифт (Inter) уже
внутри ДС и работает офлайн. Всё, что нужно лендингу, в ДС уже есть.
3. **Свой `<style>` — только на мелочи**, которых нет в ДС (позиционирование, картинка-фон).
Не переопределяй кнопки, карточки и типографику.
- max-width контента: **1140px** ---
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- border-radius: `8px` (кнопки), `16px` (карточки)
## Стартер `index.html` ## Старт: `index.html`
Это рабочий каркас лендинга. Копируй его целиком и меняй содержание.
```html ```html
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru" data-theme="auto">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title> <title>Название проекта</title>
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
<link rel="stylesheet" href="design-system/kt-ai-page.css">
<style> <style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7} /* ДС рисует кнопки для <button>; у ссылки-кнопки браузер добавляет подчёркивание */
*{box-sizing:border-box;margin:0;padding:0} a.kt-ai-btn { text-decoration: none; }
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style> </style>
</head> </head>
<body> <body>
<section class="hero"><div class="container">
<h1>Заголовок</h1> <header class="kt-ai-pagehead">
<p>Подзаголовок — пара предложений о чём это.</p> <div class="kt-ai-wrap kt-ai-pagehead-in">
<a class="btn" href="#section">Начать</a> <strong>Название</strong>
</div></section> <span class="kt-ai-spacer"></span>
<section id="section" class="section"><div class="container"> <a class="kt-ai-btn" data-variant="ghost" href="#about">О нас</a>
<h2>Секция</h2> <a class="kt-ai-btn" data-variant="primary" href="#contacts">Оставить заявку</a>
<div class="card">Контент карточки.</div> </div>
<div class="card">Контент карточки.</div> </header>
</div></section>
<section class="kt-ai-hero" data-align="center">
<div class="kt-ai-wrap">
<h1>Главное обещание в одну строку</h1>
<p class="kt-ai-hero-sub">Подзаголовок: кому это и почему стоит остаться.</p>
<div class="kt-ai-hero-actions">
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#contacts">Оставить заявку</a>
<a class="kt-ai-btn" data-size="lg" href="#about">Подробнее</a>
</div>
</div>
</section>
<section id="about" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Что вы получите</h2>
<p>Одно-два предложения пояснения к секции.</p>
</div>
<div class="kt-ai-grid" style="margin-top:var(--kt-ai-space-9)">
<article class="kt-ai-tile"><h3>Пункт первый</h3><p>Коротко и по делу.</p></article>
<article class="kt-ai-tile"><h3>Пункт второй</h3><p>Коротко и по делу.</p></article>
<article class="kt-ai-tile"><h3>Пункт третий</h3><p>Коротко и по делу.</p></article>
</div>
</div>
</section>
<section id="contacts" class="kt-ai-cta">
<div class="kt-ai-wrap">
<h2>Призыв к действию</h2>
<p>Город, адрес, часы работы — то, что нужно, чтобы дойти.</p>
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#">Написать</a>
</div>
</section>
<footer class="kt-ai-wrap kt-ai-pagefoot">
<span>© 2026 Название</span>
<span class="kt-ai-spacer"></span>
<span>Контакты</span>
</footer>
</body> </body>
</html> </html>
``` ```
## Что НЕ юзать ---
- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело ## Блоки страницы (`kt-ai-page.css`)
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS достаточно
## Что МОЖНО (если очень надо) | Класс | Что это |
|-------|---------|
| `.kt-ai-wrap` | контейнер по центру (1080px). `data-width="narrow"` — 720px, `"wide"` — 1200px |
| `.kt-ai-pagehead` + `.kt-ai-pagehead-in` | липкая шапка страницы |
| `.kt-ai-hero`, `.kt-ai-hero-sub`, `.kt-ai-hero-actions` | первый экран. `data-align="center"` — по центру |
| `.kt-ai-section` + `.kt-ai-section-head` (h2 + p) | обычная секция. `data-divider="true"` — линия сверху |
| `.kt-ai-grid` + `.kt-ai-tile` (h3 + p) | сетка плиток «возможности». `data-cols="2"/"4"`, у плитки `data-span="2"/"full"` |
| `.kt-ai-tile-icon` | иконка над заголовком плитки (спрайт — `design-system/icons/`) |
| `.kt-ai-cta` | финальный блок с призывом |
| `.kt-ai-pagefoot` | подвал |
| `.kt-ai-spacer` | распорка внутри шапки/подвала (толкает остальное вправо) |
| `.kt-ai-centered` | одна карточка по центру экрана — для страницы входа и 404 |
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>` Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно.
- **Placeholder картинки**: `https://picsum.photos/800/600`
- **Google Fonts через `<link>`** в head
## 📦 Готовые рецепты (используй их, не изобретай) ## Элементы (`kt-ai-components.css`)
| Класс | Как применять |
|-------|---------------|
| `.kt-ai-btn` | кнопка / ссылка-кнопка. `data-variant="primary"` — главная, `"ghost"` — прозрачная, `"danger"` — опасная. `data-size="sm"/"lg"` |
| `.kt-ai-card` | карточка контента |
| `.kt-ai-chip` | статус-пилюля / тег |
| `.kt-ai-link` | текстовая ссылка |
| `.kt-ai-field` + `.kt-ai-input` / `.kt-ai-textarea` / `.kt-ai-select` | поле формы с подписью |
**Один `primary` на экран.** Остальные кнопки — обычные или `ghost`.
## Токены (`kt-ai-tokens.css`)
Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`,
`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`,
`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`.
Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`.
Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`.
Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`.
**Тема** — атрибут на `<html>`: `data-theme="auto"` (по системной, ставь его по умолчанию),
`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно.
---
## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат)
Тогда экран собирается **не вёрсткой руками, а конфигом**:
1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения
(без React и npm).
2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег
`<script id="kt-app-config" type="application/json">…</script>` внутри каркаса — он сам отрисует
таблицу, KPI, фильтры, drawer и темы.
3. Проверь конфиг: `python3 design-system/scripts/validate_product.py config.json --strict` → нужен `0/0`.
Полные правила для сложных экранов — `design-system/AGENTS.md`, список компонентов —
`design-system/COMPONENTS.md`, приёмка — `design-system/CHECKLIST.md`. Читай их, когда лендинга
уже мало.
---
## 📦 Готовые рецепты
### Фото без ключей ### Фото без ключей
- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. - Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600`
- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. - По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL)
Вставляй с рамкой ДС: `<img src="…" alt="…" style="width:100%;border-radius:var(--kt-ai-radius-3xl)">`
### Кнопка WhatsApp ### Кнопка WhatsApp
```html ```html
<a href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!" style="display:inline-block;background:#25D366;color:#fff;padding:12px 22px;border-radius:8px;font-weight:700;text-decoration:none">Написать в WhatsApp</a> <a class="kt-ai-btn" data-variant="primary" data-size="lg"
href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!">Написать в WhatsApp</a>
``` ```
`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. `7XXXXXXXXXX` — номер в международном формате без `+`.
### Форма заявки без бэкенда ### Форма заявки без бэкенда
```html ```html
<form action="https://formsubmit.co/EMAIL" method="POST"> <form action="https://formsubmit.co/EMAIL" method="POST" style="max-width:420px">
<input name="name" placeholder="Имя" required> <div class="kt-ai-field"><label>Имя</label><input class="kt-ai-input" name="name" required></div>
<input name="phone" placeholder="Телефон" required> <div class="kt-ai-field"><label>Телефон</label><input class="kt-ai-input" name="phone" required></div>
<button type="submit">Отправить</button> <button class="kt-ai-btn" data-variant="primary" type="submit">Отправить</button>
</form> </form>
``` ```
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму).
### Google Fonts (максимум 2 шрифта: один display + один text) ### Favicon-эмодзи (без файла)
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&family=Unbounded:wght@700&display=swap" rel="stylesheet">
```
В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста.
### Favicon-эмодзи (inline SVG data-URI, без файла)
```html ```html
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
``` ```
Меняй эмодзи `☕` на подходящее теме.
### Карта (Google Maps embed, без ключа) ### Карта
На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть:
```html ```html
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320" style="border:0;border-radius:12px" loading="lazy"></iframe> <iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320"
style="border:0;border-radius:var(--kt-ai-radius-3xl)" loading="lazy"></iframe>
``` ```
### Плавные появления при скролле (IntersectionObserver) ### Плавные появления при скролле
```html ```html
<style> <style>
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease} .reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
@ -139,4 +214,12 @@ var io=new IntersectionObserver(function(entries){
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); }); document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
</script> </script>
``` ```
Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке.
---
## Перед тем как сказать «готово»
- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её).
- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов.
- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно).
- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum».

1193
index.html

File diff suppressed because it is too large Load Diff

712
script.js
View File

@ -1,258 +1,510 @@
document.addEventListener('DOMContentLoaded', function() { let workbook = null;
const reveals = document.querySelectorAll('.reveal'); let worksheet = null;
const faqItems = document.querySelectorAll('.faq-item'); let rawData = [];
const sizeBtns = document.querySelectorAll('.size-btn'); let fileName = '';
const contrastBtns = document.querySelectorAll('.contrast-btn');
const amountBtns = document.querySelectorAll('.amount-btn');
const customAmount = document.getElementById('custom-amount');
function checkReveal() { const uploadArea = document.getElementById('upload-area');
const triggerBottom = window.innerHeight * 0.85; const fileInput = document.getElementById('file-input');
reveals.forEach(reveal => { const fileInfo = document.getElementById('file-info');
const boxTop = reveal.getBoundingClientRect().top; const file_name = document.getElementById('file-name');
if (boxTop < triggerBottom) { const fileMeta = document.getElementById('file-meta');
reveal.classList.add('in'); const analyzeBtn = document.getElementById('analyze-btn');
} const errorMessage = document.getElementById('error-message');
}); const loading = document.getElementById('loading');
} const results = document.getElementById('results');
window.addEventListener('scroll', checkReveal); uploadArea.addEventListener('click', () => fileInput.click());
checkReveal();
faqItems.forEach(item => { uploadArea.addEventListener('keydown', (e) => {
const question = item.querySelector('.faq-question'); if (e.key === 'Enter' || e.key === ' ') {
question.addEventListener('click', function() { e.preventDefault();
const isExpanded = this.getAttribute('aria-expanded') === 'true'; fileInput.click();
faqItems.forEach(i => {
i.classList.remove('active');
i.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
});
if (!isExpanded) {
item.classList.add('active');
this.setAttribute('aria-expanded', 'true');
}
});
});
sizeBtns.forEach(btn => {
btn.addEventListener('click', function() {
sizeBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.body.classList.remove('text-normal', 'text-large', 'text-xlarge');
document.body.classList.add('text-' + this.dataset.size);
});
});
contrastBtns.forEach(btn => {
btn.addEventListener('click', function() {
contrastBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.body.classList.remove('contrast-normal', 'contrast-high');
if (this.dataset.contrast === 'high') {
document.body.classList.add('contrast-high');
}
});
});
amountBtns.forEach(btn => {
btn.addEventListener('click', function() {
amountBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
if (customAmount) customAmount.value = '';
});
});
if (customAmount) {
customAmount.addEventListener('input', function() {
amountBtns.forEach(b => b.classList.remove('active'));
});
} }
}); });
function openModal(modalId) { uploadArea.addEventListener('dragover', (e) => {
const modal = document.getElementById(modalId); e.preventDefault();
if (modal) { uploadArea.classList.add('dragover');
modal.removeAttribute('hidden'); });
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable) focusable.focus();
modal.addEventListener('keydown', function(e) { uploadArea.addEventListener('dragleave', () => {
if (e.key === 'Escape') { uploadArea.classList.remove('dragover');
closeModal(modalId); });
}
if (e.key === 'Tab') { uploadArea.addEventListener('drop', (e) => {
const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); e.preventDefault();
const first = focusableElements[0]; uploadArea.classList.remove('dragover');
const last = focusableElements[focusableElements.length - 1]; const files = e.dataTransfer.files;
if (e.shiftKey && document.activeElement === first) { if (files.length > 0) {
e.preventDefault(); handleFile(files[0]);
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
} }
} });
function closeModal(modalId) { fileInput.addEventListener('change', (e) => {
const modal = document.getElementById(modalId); if (e.target.files.length > 0) {
if (modal) { handleFile(e.target.files[0]);
modal.setAttribute('hidden', '');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
} }
} });
let currentStep = 1; function handleFile(file) {
const totalSteps = 10; const validTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'application/vnd.ms-excel.sheet.macroEnabled.12'
];
function showStep(step) { const ext = file.name.split('.').pop().toLowerCase();
const steps = document.querySelectorAll('.form-step:not(.result-step)'); const isValid = validTypes.includes(file.type) ||
steps.forEach((s, index) => { ['xlsx', 'xls', 'csv'].includes(ext);
s.classList.remove('active');
if (index === step - 1) {
s.classList.add('active');
}
});
currentStep = step;
}
function nextStep(current) { if (!isValid) {
const currentStepEl = document.querySelectorAll('.form-step:not(.result-step)')[current - 1]; showError('Неподдерживаемый формат файла. Загрузите .xlsx, .xls или .csv');
const inputs = currentStepEl.querySelectorAll('input[type="radio"], input[type="checkbox"]');
let answered = false;
inputs.forEach(input => {
if (input.checked) answered = true;
});
if (!answered) {
alert('Пожалуйста, выберите вариант ответа');
return; return;
} }
if (current < totalSteps) { fileName = file.name;
showStep(current + 1); file_name.textContent = '📄 ' + file.name;
} fileMeta.textContent = 'Размер: ' + formatFileSize(file.size);
fileInfo.classList.add('visible');
uploadArea.classList.add('has-file');
analyzeBtn.disabled = false;
hideError();
results.classList.remove('visible');
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
workbook = XLSX.read(data, { type: 'array' });
if (workbook.SheetNames.length > 0) {
worksheet = workbook.Sheets[workbook.SheetNames[0]];
rawData = XLSX.utils.sheet_to_json(worksheet, { defval: '' });
if (rawData.length === 0) {
showError('Файл пуст или не содержит данных');
analyzeBtn.disabled = true;
return;
}
analyzeBtn.disabled = false;
}
} catch (err) {
showError('Ошибка чтения файла: ' + err.message);
analyzeBtn.disabled = true;
}
};
reader.onerror = () => {
showError('Ошибка при чтении файла');
analyzeBtn.disabled = true;
};
reader.readAsArrayBuffer(file);
} }
function prevStep(current) { analyzeBtn.addEventListener('click', () => {
if (current > 1) { if (rawData.length === 0) {
showStep(current - 1); showError('Нет данных для анализа');
return;
} }
loading.classList.add('visible');
results.classList.remove('visible');
setTimeout(() => {
try {
performAnalysis();
loading.classList.remove('visible');
results.classList.add('visible');
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
loading.classList.remove('visible');
showError('Ошибка анализа: ' + err.message);
}
}, 500);
});
function performAnalysis() {
const columns = Object.keys(rawData[0]);
const stats = calculateStats(rawData, columns);
const insights = generateInsights(rawData, columns, stats);
renderStats(stats);
renderInsights(insights);
renderCharts(rawData, columns, stats);
renderTable(rawData, columns);
} }
function submitTest() { function calculateStats(data, columns) {
const resultsDiv = document.getElementById('test-results'); const stats = {
const answers = { totalRows: data.length,
disability: document.querySelector('input[name="disability"]:checked'), totalColumns: columns.length,
work: document.querySelector('input[name="work"]:checked'), columns: columns,
location: document.querySelector('input[name="location"]:checked'), emptyCells: 0,
income: document.querySelector('input[name="income"]:checked'), numericColumns: [],
housing: document.querySelector('input[name="housing"]:checked'), textColumns: [],
tsr: document.querySelectorAll('input[name="tsr"]:checked'), dateColumns: [],
employment: document.querySelector('input[name="employment"]:checked'), columnStats: {}
children: document.querySelector('input[name="children"]:checked'),
legal: document.querySelector('input[name="legal"]:checked'),
volunteer: document.querySelector('input[name="volunteer"]:checked')
}; };
let results = []; columns.forEach(col => {
const values = data.map(row => row[col]).filter(v => v !== '' && v !== null && v !== undefined);
const emptyCount = data.length - values.length;
stats.emptyCells += emptyCount;
if (answers.disability && answers.disability.value !== 'none') { const firstValue = values[0];
results.push({ const isNumeric = values.every(v => !isNaN(parseFloat(v)) && isFinite(v));
title: 'Пенсия по инвалидности', const isDate = values.some(v => {
desc: 'Положена всем гражданам с инвалидностью. Размер зависит от группы.' const d = new Date(v);
return !isNaN(d.getTime());
}); });
results.push({
title: 'Ежемесячная выплата (ЕДВ)',
desc: 'Дополнительная выплата для отдельных категорий.'
});
}
if (answers.income && answers.income.value === 'low') { if (isNumeric) {
results.push({ stats.numericColumns.push(col);
title: 'Материальная помощь', const numValues = values.map(v => parseFloat(v));
desc: 'Единовременная выплата в трудной жизненной ситуации.' stats.columnStats[col] = {
}); type: 'numeric',
} min: Math.min(...numValues),
max: Math.max(...numValues),
if (answers.housing && answers.housing.value === 'yes') { avg: numValues.reduce((a, b) => a + b, 0) / numValues.length,
results.push({ sum: numValues.reduce((a, b) => a + b, 0),
title: 'Субсидия на оплату ЖКУ', empty: emptyCount
desc: 'Компенсация до 50% расходов на коммунальные услуги.' };
}); } else if (isDate) {
results.push({ stats.dateColumns.push(col);
title: 'Программа доступного жилья', stats.columnStats[col] = {
desc: 'Постановка в очередь на социальное жильё.' type: 'date',
}); empty: emptyCount
} };
} else {
if (answers.tsr && answers.tsr.length > 0) { stats.textColumns.push(col);
results.push({ const unique = [...new Set(values)];
title: 'Технические средства реабилитации', const counts = {};
desc: 'Бесплатное получение колясок, протезов и других ТСР.' values.forEach(v => { counts[v] = (counts[v] || 0) + 1; });
}); const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
} stats.columnStats[col] = {
type: 'text',
if (answers.employment && answers.employment.value === 'yes') { unique: unique.length,
results.push({ top: sorted.slice(0, 5),
title: 'Содействие в трудоустройстве', empty: emptyCount
desc: 'Квоты для людей с инвалидностью, программы переобучения.' };
});
}
if (answers.volunteer && answers.volunteer.value !== 'no') {
results.push({
title: 'Волонтёрская поддержка',
desc: 'Помощь волонтёров в быту, сопровождении, транспорте.'
});
}
if (answers.children && answers.children.value === 'yes') {
results.push({
title: 'Пособие на детей',
desc: 'Дополнительные выплаты на детей в семье.'
});
}
if (answers.legal && answers.legal.value === 'yes') {
results.push({
title: 'Юридическая помощь',
desc: 'Бесплатные консультации юриста по вопросам инвалидности.'
});
}
if (results.length === 0) {
results.push({
title: 'Обратитесь в центр соцзащиты',
desc: 'Специалисты подберут индивидуальную программу поддержки.'
});
}
resultsDiv.innerHTML = results.map(r =>
'<div class="result-item"><h4>' + r.title + '</h4><p>' + r.desc + '</p></div>'
).join('');
const steps = document.querySelectorAll('.form-step:not(.result-step)');
steps.forEach(s => s.classList.remove('active'));
document.querySelector('.result-step').classList.add('active');
}
function resetTest() {
const form = document.getElementById('test-form');
form.reset();
showStep(1);
}
if (window.location.hash) {
setTimeout(function() {
const element = document.querySelector(window.location.hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
} }
}, 100); });
return stats;
}
function generateInsights(data, columns, stats) {
const insights = [];
insights.push({
icon: '📊',
text: `<strong>Всего записей:</strong> ${stats.totalRows} в ${stats.totalColumns} колонках`
});
if (stats.numericColumns.length > 0) {
insights.push({
icon: '💰',
text: `<strong>Числовые колонки:</strong> ${stats.numericColumns.join(', ')}`
});
stats.numericColumns.forEach(col => {
const s = stats.columnStats[col];
if (s && s.type === 'numeric') {
insights.push({
icon: '📈',
text: `<strong>${col}:</strong> среднее = ${formatNumber(s.avg)}, мин = ${formatNumber(s.min)}, макс = ${formatNumber(s.max)}`
});
}
});
}
if (stats.textColumns.length > 0) {
insights.push({
icon: '🏷️',
text: `<strong>Текстовые колонки:</strong> ${stats.textColumns.join(', ')}`
});
stats.textColumns.forEach(col => {
const s = stats.columnStats[col];
if (s && s.type === 'text' && s.top.length > 0) {
const topList = s.top.map(item => item[0]).slice(0, 3).join(', ');
insights.push({
icon: '🔝',
text: `<strong>${col}:</strong> чаще всего встречается "${topList}"`
});
}
});
}
const emptyPercent = ((stats.emptyCells / (stats.totalRows * stats.totalColumns)) * 100).toFixed(1);
if (emptyPercent > 10) {
insights.push({
icon: '⚠️',
text: `<strong>Внимание:</strong> ${emptyPercent}% пустых ячеек — проверьте полноту данных`
});
}
return insights;
}
function renderStats(stats) {
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="stat-card">
<div class="stat-value">${stats.totalRows}</div>
<div class="stat-label">Всего записей</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalColumns}</div>
<div class="stat-label">Колонок</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.numericColumns.length}</div>
<div class="stat-label">Числовых полей</div>
</div>
<div class="stat-card">
<div class="stat-value">${formatNumber(stats.emptyCells)}</div>
<div class="stat-label">Пустых ячеек</div>
</div>
`;
}
function renderInsights(insights) {
const list = document.getElementById('insights-list');
list.innerHTML = insights.map(i => `
<div class="insight-item">
<div class="insight-icon">${i.icon}</div>
<div class="insight-text">${i.text}</div>
</div>
`).join('');
}
let charts = [];
function renderCharts(data, columns, stats) {
const container = document.getElementById('charts-section');
container.innerHTML = '';
charts.forEach(chart => chart.destroy());
charts = [];
if (stats.numericColumns.length >= 2) {
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Распределение числовых данных</h3>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const ctx = document.getElementById('barChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: stats.numericColumns.slice(0, 10),
datasets: [{
label: 'Среднее значение',
data: stats.numericColumns.slice(0, 10).map(col =>
stats.columnStats[col] ? stats.columnStats[col].avg : 0
),
backgroundColor: 'rgba(99, 102, 241, 0.6)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
charts.push(chart);
}
if (stats.textColumns.length > 0 && stats.textColumns.some(col =>
stats.columnStats[col] && stats.columnStats[col].unique <= 10
)) {
const textCol = stats.textColumns.find(col =>
stats.columnStats[col] && stats.columnStats[col].unique <= 10
);
if (textCol) {
const s = stats.columnStats[textCol];
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Распределение: ${textCol}</h3>
<div class="chart-container">
<canvas id="pieChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const ctx = document.getElementById('pieChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'pie',
data: {
labels: s.top.map(item => item[0]),
datasets: [{
data: s.top.map(item => item[1]),
backgroundColor: [
'rgba(99, 102, 241, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(245, 158, 11, 0.8)',
'rgba(239, 68, 68, 0.8)',
'rgba(139, 92, 246, 0.8)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
charts.push(chart);
}
}
if (stats.numericColumns.length >= 1 && data.length <= 100) {
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Динамика данных</h3>
<div class="chart-container">
<canvas id="lineChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const firstNumeric = stats.numericColumns[0];
const values = data.slice(0, 50).map((_, i) =>
data[i] && data[i][firstNumeric] ? parseFloat(data[i][firstNumeric]) : 0
);
const ctx = document.getElementById('lineChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: values.map((_, i) => i + 1),
datasets: [{
label: firstNumeric,
data: values,
borderColor: 'rgba(16, 185, 129, 1)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
},
scales: {
y: {
beginAtZero: false
}
}
}
});
charts.push(chart);
}
}
function renderTable(data, columns) {
const table = document.getElementById('data-table');
const maxRows = Math.min(100, data.length);
let html = '<thead><tr>';
columns.forEach(col => {
html += '<th>' + escapeHtml(col) + '</th>';
});
html += '</tr></thead><tbody>';
for (let i = 0; i < maxRows; i++) {
html += '<tr>';
columns.forEach(col => {
html += '<td>' + escapeHtml(data[i][col]) + '</td>';
});
html += '</tr>';
}
if (data.length > maxRows) {
html += '<tr><td colspan="' + columns.length + '" style="text-align:center;color:#718096;">';
html += '... показано 100 из ' + data.length + ' записей ...';
html += '</td></tr>';
}
html += '</tbody>';
table.innerHTML = html;
}
function exportToCSV() {
if (!rawData || rawData.length === 0) return;
const ws = XLSX.utils.json_to_sheet(rawData);
const csv = XLSX.utils.sheet_to_csv(ws);
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName.replace(/\.[^/.]+$/, '') + '_analysis.csv';
link.click();
}
function exportToExcel() {
if (!rawData || rawData.length === 0) return;
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(rawData);
XLSX.utils.book_append_sheet(wb, ws, 'Data');
XLSX.writeFile(wb, fileName.replace(/\.[^/.]+$/, '') + '_analysis.xlsx');
}
function showError(msg) {
errorMessage.textContent = msg;
errorMessage.classList.add('visible');
}
function hideError() {
errorMessage.classList.remove('visible');
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / 1048576).toFixed(1) + ' МБ';
}
function formatNumber(num) {
if (typeof num !== 'number') return num;
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return Math.round(num * 100) / 100;
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
} }