site: анализ данных — загрузка Excel/CSV файлов
This commit is contained in:
parent
8f2cfcfad7
commit
172f1148af
12
AGENTS.md
12
AGENTS.md
@ -1,4 +1,4 @@
|
||||
<!-- vibe42-agents-version: v36-makexlsx-2026-07-31 -->
|
||||
<!-- vibe42-agents-version: v37-ktai-ds-2026-09-01 -->
|
||||
# Vibe42 — учебная песочница: сайты, боты и первые приложения
|
||||
|
||||
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 ниже |
|
||||
| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже |
|
||||
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же 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
249
design.md
@ -1,132 +1,207 @@
|
||||
<!-- vibe42-design-version: v2-recipes-2026-07-07 -->
|
||||
# Design system — Vibe42 песочница
|
||||
<!-- vibe42-design-version: v3-ktai-ds-2026-09-01 -->
|
||||
# Дизайн-система KT AI — по ней собирается весь UI
|
||||
|
||||
Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться.
|
||||
В папке проекта лежит `design-system/` — это **дизайн-система KT AI**
|
||||
(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`).
|
||||
Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга.
|
||||
|
||||
## Палитра
|
||||
**Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу
|
||||
из готовых классов ДС и наполнить её содержанием юзера.
|
||||
|
||||
| Token | Hex | Использование |
|
||||
|-------|-----|---------------|
|
||||
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
|
||||
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
|
||||
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
|
||||
| `--white` | `#FFFFFF` | Основной фон |
|
||||
| `--gray-500` | `#5B6573` | Вторичный текст |
|
||||
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
|
||||
Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз:
|
||||
|
||||
## Типографика
|
||||
|
||||
```css
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
```bash
|
||||
cp -r /srv/opencode/design-system/kt-ai design-system
|
||||
```
|
||||
|
||||
| Уровень | Размер | Вес | 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
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
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}}
|
||||
/* ДС рисует кнопки для <button>; у ссылки-кнопки браузер добавляет подчёркивание */
|
||||
a.kt-ai-btn { text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero"><div class="container">
|
||||
<h1>Заголовок</h1>
|
||||
<p>Подзаголовок — пара предложений о чём это.</p>
|
||||
<a class="btn" href="#section">Начать</a>
|
||||
</div></section>
|
||||
<section id="section" class="section"><div class="container">
|
||||
<h2>Секция</h2>
|
||||
<div class="card">Контент карточки.</div>
|
||||
<div class="card">Контент карточки.</div>
|
||||
</div></section>
|
||||
|
||||
<header class="kt-ai-pagehead">
|
||||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||||
<strong>Название</strong>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<a class="kt-ai-btn" data-variant="ghost" href="#about">О нас</a>
|
||||
<a class="kt-ai-btn" data-variant="primary" href="#contacts">Оставить заявку</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Что НЕ юзать
|
||||
---
|
||||
|
||||
- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело
|
||||
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
|
||||
- jQuery — vanilla JS достаточно
|
||||
## Блоки страницы (`kt-ai-page.css`)
|
||||
|
||||
## Что МОЖНО (если очень надо)
|
||||
| Класс | Что это |
|
||||
|-------|---------|
|
||||
| `.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>`
|
||||
- **Placeholder картинки**: `https://picsum.photos/800/600`
|
||||
- **Google Fonts через `<link>`** в head
|
||||
Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно.
|
||||
|
||||
## 📦 Готовые рецепты (используй их, не изобретай)
|
||||
## Элементы (`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://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу.
|
||||
- Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600`
|
||||
- По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL)
|
||||
|
||||
Вставляй с рамкой ДС: `<img src="…" alt="…" style="width:100%;border-radius:var(--kt-ai-radius-3xl)">`
|
||||
|
||||
### Кнопка WhatsApp
|
||||
```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
|
||||
<form action="https://formsubmit.co/EMAIL" method="POST">
|
||||
<input name="name" placeholder="Имя" required>
|
||||
<input name="phone" placeholder="Телефон" required>
|
||||
<button type="submit">Отправить</button>
|
||||
<form action="https://formsubmit.co/EMAIL" method="POST" style="max-width:420px">
|
||||
<div class="kt-ai-field"><label>Имя</label><input class="kt-ai-input" name="name" required></div>
|
||||
<div class="kt-ai-field"><label>Телефон</label><input class="kt-ai-input" name="phone" required></div>
|
||||
<button class="kt-ai-btn" data-variant="primary" type="submit">Отправить</button>
|
||||
</form>
|
||||
```
|
||||
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз).
|
||||
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму).
|
||||
|
||||
### Google Fonts (максимум 2 шрифта: один display + один text)
|
||||
```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, без файла)
|
||||
### Favicon-эмодзи (без файла)
|
||||
```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>">
|
||||
```
|
||||
Меняй эмодзи `☕` на подходящее теме.
|
||||
|
||||
### Карта (Google Maps embed, без ключа)
|
||||
На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть:
|
||||
### Карта
|
||||
На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть:
|
||||
```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
|
||||
<style>
|
||||
.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); });
|
||||
</script>
|
||||
```
|
||||
Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке.
|
||||
|
||||
---
|
||||
|
||||
## Перед тем как сказать «готово»
|
||||
|
||||
- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её).
|
||||
- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов.
|
||||
- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно).
|
||||
- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum».
|
||||
|
||||
1193
index.html
1193
index.html
File diff suppressed because it is too large
Load Diff
668
script.js
668
script.js
@ -1,258 +1,510 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const reveals = document.querySelectorAll('.reveal');
|
||||
const faqItems = document.querySelectorAll('.faq-item');
|
||||
const sizeBtns = document.querySelectorAll('.size-btn');
|
||||
const contrastBtns = document.querySelectorAll('.contrast-btn');
|
||||
const amountBtns = document.querySelectorAll('.amount-btn');
|
||||
const customAmount = document.getElementById('custom-amount');
|
||||
let workbook = null;
|
||||
let worksheet = null;
|
||||
let rawData = [];
|
||||
let fileName = '';
|
||||
|
||||
function checkReveal() {
|
||||
const triggerBottom = window.innerHeight * 0.85;
|
||||
reveals.forEach(reveal => {
|
||||
const boxTop = reveal.getBoundingClientRect().top;
|
||||
if (boxTop < triggerBottom) {
|
||||
reveal.classList.add('in');
|
||||
}
|
||||
});
|
||||
}
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
const file_name = document.getElementById('file-name');
|
||||
const fileMeta = document.getElementById('file-meta');
|
||||
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);
|
||||
checkReveal();
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question');
|
||||
question.addEventListener('click', function() {
|
||||
const isExpanded = this.getAttribute('aria-expanded') === 'true';
|
||||
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) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
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) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal(modalId);
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
const first = focusableElements[0];
|
||||
const last = focusableElements[focusableElements.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
uploadArea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.setAttribute('hidden', '');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
let currentStep = 1;
|
||||
const totalSteps = 10;
|
||||
|
||||
function showStep(step) {
|
||||
const steps = document.querySelectorAll('.form-step:not(.result-step)');
|
||||
steps.forEach((s, index) => {
|
||||
s.classList.remove('active');
|
||||
if (index === step - 1) {
|
||||
s.classList.add('active');
|
||||
}
|
||||
});
|
||||
currentStep = step;
|
||||
}
|
||||
|
||||
function nextStep(current) {
|
||||
const currentStepEl = document.querySelectorAll('.form-step:not(.result-step)')[current - 1];
|
||||
const inputs = currentStepEl.querySelectorAll('input[type="radio"], input[type="checkbox"]');
|
||||
let answered = false;
|
||||
inputs.forEach(input => {
|
||||
if (input.checked) answered = true;
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
if (!answered) {
|
||||
alert('Пожалуйста, выберите вариант ответа');
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
const validTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12'
|
||||
];
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const isValid = validTypes.includes(file.type) ||
|
||||
['xlsx', 'xls', 'csv'].includes(ext);
|
||||
|
||||
if (!isValid) {
|
||||
showError('Неподдерживаемый формат файла. Загрузите .xlsx, .xls или .csv');
|
||||
return;
|
||||
}
|
||||
|
||||
if (current < totalSteps) {
|
||||
showStep(current + 1);
|
||||
}
|
||||
fileName = file.name;
|
||||
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;
|
||||
}
|
||||
|
||||
function prevStep(current) {
|
||||
if (current > 1) {
|
||||
showStep(current - 1);
|
||||
analyzeBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Ошибка чтения файла: ' + err.message);
|
||||
analyzeBtn.disabled = true;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showError('Ошибка при чтении файла');
|
||||
analyzeBtn.disabled = true;
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
function submitTest() {
|
||||
const resultsDiv = document.getElementById('test-results');
|
||||
const answers = {
|
||||
disability: document.querySelector('input[name="disability"]:checked'),
|
||||
work: document.querySelector('input[name="work"]:checked'),
|
||||
location: document.querySelector('input[name="location"]:checked'),
|
||||
income: document.querySelector('input[name="income"]:checked'),
|
||||
housing: document.querySelector('input[name="housing"]:checked'),
|
||||
tsr: document.querySelectorAll('input[name="tsr"]:checked'),
|
||||
employment: document.querySelector('input[name="employment"]:checked'),
|
||||
children: document.querySelector('input[name="children"]:checked'),
|
||||
legal: document.querySelector('input[name="legal"]:checked'),
|
||||
volunteer: document.querySelector('input[name="volunteer"]:checked')
|
||||
analyzeBtn.addEventListener('click', () => {
|
||||
if (rawData.length === 0) {
|
||||
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 calculateStats(data, columns) {
|
||||
const stats = {
|
||||
totalRows: data.length,
|
||||
totalColumns: columns.length,
|
||||
columns: columns,
|
||||
emptyCells: 0,
|
||||
numericColumns: [],
|
||||
textColumns: [],
|
||||
dateColumns: [],
|
||||
columnStats: {}
|
||||
};
|
||||
|
||||
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') {
|
||||
results.push({
|
||||
title: 'Пенсия по инвалидности',
|
||||
desc: 'Положена всем гражданам с инвалидностью. Размер зависит от группы.'
|
||||
const firstValue = values[0];
|
||||
const isNumeric = values.every(v => !isNaN(parseFloat(v)) && isFinite(v));
|
||||
const isDate = values.some(v => {
|
||||
const d = new Date(v);
|
||||
return !isNaN(d.getTime());
|
||||
});
|
||||
results.push({
|
||||
title: 'Ежемесячная выплата (ЕДВ)',
|
||||
desc: 'Дополнительная выплата для отдельных категорий.'
|
||||
|
||||
if (isNumeric) {
|
||||
stats.numericColumns.push(col);
|
||||
const numValues = values.map(v => parseFloat(v));
|
||||
stats.columnStats[col] = {
|
||||
type: 'numeric',
|
||||
min: Math.min(...numValues),
|
||||
max: Math.max(...numValues),
|
||||
avg: numValues.reduce((a, b) => a + b, 0) / numValues.length,
|
||||
sum: numValues.reduce((a, b) => a + b, 0),
|
||||
empty: emptyCount
|
||||
};
|
||||
} else if (isDate) {
|
||||
stats.dateColumns.push(col);
|
||||
stats.columnStats[col] = {
|
||||
type: 'date',
|
||||
empty: emptyCount
|
||||
};
|
||||
} else {
|
||||
stats.textColumns.push(col);
|
||||
const unique = [...new Set(values)];
|
||||
const counts = {};
|
||||
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',
|
||||
unique: unique.length,
|
||||
top: sorted.slice(0, 5),
|
||||
empty: emptyCount
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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 (answers.income && answers.income.value === 'low') {
|
||||
results.push({
|
||||
title: 'Материальная помощь',
|
||||
desc: 'Единовременная выплата в трудной жизненной ситуации.'
|
||||
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}"`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (answers.housing && answers.housing.value === 'yes') {
|
||||
results.push({
|
||||
title: 'Субсидия на оплату ЖКУ',
|
||||
desc: 'Компенсация до 50% расходов на коммунальные услуги.'
|
||||
});
|
||||
results.push({
|
||||
title: 'Программа доступного жилья',
|
||||
desc: 'Постановка в очередь на социальное жильё.'
|
||||
const emptyPercent = ((stats.emptyCells / (stats.totalRows * stats.totalColumns)) * 100).toFixed(1);
|
||||
if (emptyPercent > 10) {
|
||||
insights.push({
|
||||
icon: '⚠️',
|
||||
text: `<strong>Внимание:</strong> ${emptyPercent}% пустых ячеек — проверьте полноту данных`
|
||||
});
|
||||
}
|
||||
|
||||
if (answers.tsr && answers.tsr.length > 0) {
|
||||
results.push({
|
||||
title: 'Технические средства реабилитации',
|
||||
desc: 'Бесплатное получение колясок, протезов и других ТСР.'
|
||||
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 (answers.employment && answers.employment.value === 'yes') {
|
||||
results.push({
|
||||
title: 'Содействие в трудоустройстве',
|
||||
desc: 'Квоты для людей с инвалидностью, программы переобучения.'
|
||||
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 (answers.volunteer && answers.volunteer.value !== 'no') {
|
||||
results.push({
|
||||
title: 'Волонтёрская поддержка',
|
||||
desc: 'Помощь волонтёров в быту, сопровождении, транспорте.'
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.children && answers.children.value === 'yes') {
|
||||
results.push({
|
||||
title: 'Пособие на детей',
|
||||
desc: 'Дополнительные выплаты на детей в семье.'
|
||||
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>';
|
||||
|
||||
if (answers.legal && answers.legal.value === 'yes') {
|
||||
results.push({
|
||||
title: 'Юридическая помощь',
|
||||
desc: 'Бесплатные консультации юриста по вопросам инвалидности.'
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
html += '<tr>';
|
||||
columns.forEach(col => {
|
||||
html += '<td>' + escapeHtml(data[i][col]) + '</td>';
|
||||
});
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
results.push({
|
||||
title: 'Обратитесь в центр соцзащиты',
|
||||
desc: 'Специалисты подберут индивидуальную программу поддержки.'
|
||||
});
|
||||
if (data.length > maxRows) {
|
||||
html += '<tr><td colspan="' + columns.length + '" style="text-align:center;color:#718096;">';
|
||||
html += '... показано 100 из ' + data.length + ' записей ...';
|
||||
html += '</td></tr>';
|
||||
}
|
||||
|
||||
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');
|
||||
html += '</tbody>';
|
||||
table.innerHTML = html;
|
||||
}
|
||||
|
||||
function resetTest() {
|
||||
const form = document.getElementById('test-form');
|
||||
form.reset();
|
||||
showStep(1);
|
||||
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();
|
||||
}
|
||||
|
||||
if (window.location.hash) {
|
||||
setTimeout(function() {
|
||||
const element = document.querySelector(window.location.hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
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');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user