ваше сообщение коммита

This commit is contained in:
2025-07-15 14:14:53 +03:00
parent 584ff401ad
commit d952e89a26
10 changed files with 1109 additions and 124 deletions

406
SOFT_DESCRIPTION.md Normal file
View File

@@ -0,0 +1,406 @@
# DApp for Business
## Краткое описание
**DApp for Business** — это современное веб3-приложение, позволяющее предпринимателям использовать цифровое юридическое лицо для обслуживания клиентов, приема безналичных платежей, безопасного управления активами и прозрачного учета с помощью смарт-контрактов и искусственного интеллекта.
---
## Основные возможности
- Создание и управление цифровым юридическим лицом на блокчейне
- Прием безналичных платежей (криптовалюта, стейблкоины)
- Безопасное хранение и управление бизнес-активами через смарт-контракты
- Прозрачный учет операций и автоматизация отчетности
- Интеграция с ИИ для анализа данных и автоматизации бизнес-процессов
- Управление клиентской базой и сервисами через децентрализованное приложение
---
## Целевая аудитория
- Индивидуальные предприниматели
- Малый и средний бизнес
- Стартапы, работающие с цифровыми активами
- Фрилансеры, желающие автоматизировать бизнес-процессы
---
## Преимущества
- Полная прозрачность и доверие благодаря блокчейну
- Автоматизация рутинных задач с помощью ИИ
- Безопасное управление активами без посредников
- Гибкая интеграция с внешними сервисами и кошельками
- Масштабируемость и независимость от традиционных банковских систем
---
## Технические требования
- Операционная система: Linux, macOS, Windows (рекомендуется WSL2 для Windows)
- Docker
- Node.js (v16+)
- Yarn
- Браузер с поддержкой Web3 (например, MetaMask)
---
## Установка и запуск
1. Клонируйте репозиторий:
```bash
git clone https://github.com/your-org/dapp-for-business.git
cd dapp-for-business
```
2. Установите зависимости:
```bash
yarn install
```
3. Запустите приложение в Docker:
```bash
docker-compose up --build
```
4. Откройте приложение в браузере по адресу: [http://localhost:3000](http://localhost:3000)
---
## Быстрый старт
1. Зарегистрируйте цифровое юридическое лицо через интерфейс приложения.
2. Подключите криптокошелек (MetaMask или другой Web3-кошелек).
3. Настройте параметры бизнеса и добавьте клиентов.
4. Начните принимать платежи и управлять активами через смарт-контракты.
---
## Структура проекта
- `frontend/` — клиентская часть на Vue.js (исходный код, конфиги, сборка, nginx)
- `backend/` — серверная логика, API, работа с БД, интеграция со смарт-контрактами
- `vector-search/` — сервис поиска и работы с векторными данными (Python, FastAPI)
- `webssh-agent/` — сервис для работы с SSH-агентом через веб-интерфейс (Node.js)
- `scripts/` — bash-скрипты для автоматизации (миграции, обновления DNS и др.)
- `md/` — дополнительная документация и технические описания
- `docs/` — документация и примеры
- `docker-compose.yml`, `Dockerfile` — конфигурация контейнеризации и сборки
- `setup.sh`, `clean-logs.sh` — вспомогательные скрипты для установки и обслуживания
---
## Часто задаваемые вопросы (FAQ)
- **Как подключить кошелек?**
Используйте MetaMask или любой другой Web3-кошелек, следуя инструкции в интерфейсе приложения.
- **Какие криптовалюты поддерживаются?**
Поддерживаются основные токены стандарта ERC-20 и стейблкоины.
- **Безопасно ли хранить активы в приложении?**
Все операции выполняются через проверенные смарт-контракты, код которых открыт и доступен для аудита.
---
## Особенности развертывания и автономной работы
- Программное обеспечение с ИИ может работать полностью автономно на локальном устройстве без доступа к интернету после установки.
- С локального устройства возможно открыть доступ к сервису для интернет-пользователей (например, через проброс портов или настройку прокси).
- Приложение может быть установлено и сразу в облачный сервис (VPS, облачные платформы и т.д.) для круглосуточного доступа из любой точки мира.
---
## Управление настройками и доступом
- Управление всеми настройками приложения осуществляется через удобный веб-интерфейс.
- Доступ к настройкам получает только пользователь, чей криптокошелек содержит специальный админ-токен управления смарт-контрактом.
- После первого входа в приложение администратор может изменить стандартный админ-токен, создав собственный токен через специальную форму для деплоя смарт-контракта прямо в интерфейсе приложения.
- Таким образом, ваш кошелек — это ваш смарт-контракт, ваши токены, ваш управляемый доступ и ваши активы: только вы контролируете все ключевые параметры и права.
- Для получения бесплатных обновлений приложения в течение 5 лет администратору необходимо отправить первичный админ-токен на собственный смарт-контракт, созданный в приложении (операция выполняется через интерфейс).
> **Примечание:** Все смарт-контракты приложения построены на базе проверенных и безопасных библиотек OpenZeppelin.
---
## Инструкция по заполнению формы «Блокчейн-настройки»
1. **Создание нового DLE (Digital Legal Entity)**
- **Имя DLE**: Введите уникальное название вашей цифровой организации (например, My DLE).
- **Символ токена управления (GT)**: Укажите короткое обозначение токена (3-5 латинских символов, например, MDGT).
2. **Выбор кода деятельности (ISIC)**
- Последовательно выберите секцию, раздел, группу и класс деятельности из выпадающих списков.
- После выбора нужного кода нажмите «Добавить код деятельности». Можно добавить несколько кодов.
3. **Партнёры**
- Для каждого партнёра укажите:
- **Адрес партнёра** (Ethereum-адрес, например, 0x...).
- **Сумму GT для партнёра** (количество токенов управления).
- Для добавления нового партнёра используйте кнопку «Добавить партнёра».
- Для удаления — «Удалить партнёра».
4. **RPC-конфигурации**
- В разделе «Добавить новую RPC конфигурацию» выберите сеть из списка или укажите пользовательский ID и Chain ID.
- Введите RPC URL (например, https://...).
- При необходимости воспользуйтесь предложенным URL.
- Нажмите «Добавить RPC» для сохранения конфигурации.
5. **Выбор сети для деплоя**
- В выпадающем списке выберите блокчейн-сеть, в которую будет развёрнут смарт-контракт вашей организации.
6. **Приватный ключ для деплоя**
- Введите приватный ключ деплоера (будет использоваться только для развертывания смарт-контракта).
7. **Пользовательские настройки газа (опционально)**
- Если требуется, включите опцию «Использовать пользовательские настройки газа» и укажите лимит газа, максимальную и приоритетную комиссию.
8. **Сохранение и деплой**
- После заполнения всех полей проверьте введённые данные.
- Нажмите кнопку деплоя (или «Сохранить»), чтобы развернуть смарт-контракт и завершить настройку.
**Важно:**
- Доступ к настройкам имеют только пользователи с админ-токеном в кошельке.
- После деплоя вы сможете управлять организацией через смарт-контракт и веб-интерфейс.
---
## Управление DLE и модульными смарт-контрактами
После создания смарт-контракта с админ-токеном управления в разделе CRM приложения появляется интерфейс для управления функциями смарт-контракта и добавления модульных смарт-контрактов.
### Как управлять DLE через интерфейс
1. **Переход к управлению DLE**
- В разделе CRM нажмите на блок "Управление DLE" и кнопку "Подробнее" или перейдите по адресу `/dle-management`.
2. **Выбор DLE**
- В списке отобразятся все созданные вами DLE. Выберите нужную организацию для управления.
3. **Основная информация**
- Вкладка "Основная информация" содержит сведения о названии, символе токена, местонахождении, кодах деятельности, дате создания и адресах смарт-контрактов (токен, таймлок, Governor). Можно скопировать адреса или открыть их в обозревателе блокчейна.
4. **Предложения**
- На вкладке "Предложения" можно создавать новые предложения для управления DLE (например, изменение параметров, добавление участников и др.).
- Для создания предложения заполните заголовок и описание, затем отправьте на голосование.
- Участвуйте в голосовании по предложениям ("За" или "Против"). Статус предложения обновляется автоматически.
5. **Управление (Governance)**
- Вкладка "Управление" позволяет просматривать и изменять параметры управления: порог предложения, кворум, задержку и период голосования.
6. **Модули**
- Вкладка "Модули" предназначена для подключения дополнительных модульных смарт-контрактов (например, токенизация активов, мультиподпись, дивиденды, стейкинг, приём платежей и др.).
- Для установки модуля нажмите "Установить" напротив нужного модуля. Для удаления — "Удалить".
- Для модуля "Прием платежей" выберите токены, которые будут приниматься, и сохраните настройки.
7. **Удаление DLE**
- При необходимости можно удалить DLE (доступно только администратору). Будьте внимательны: действие необратимо.
**Важно:**
- Все действия с DLE и модулями доступны только пользователям с админ-токеном в кошельке.
- Управление DLE реализовано через смарт-контракты на базе OpenZeppelin, что обеспечивает безопасность и прозрачность операций.
---
## Настройка и использование ИИ-моделей
После установки приложения администратор может:
- Добавить ключи своих ИИ-моделей по подписке (например, OpenAI, Gemini и др.) в настройках приложения.
- Скачать одну из доступных ИИ-моделей и развернуть её локально на своём устройстве для автоматизации обслуживания клиентов бизнеса без рисков утечки конфиденциальных данных.
Для дообучения добавленных моделей деталям вашего бизнеса предусмотрена возможность:
- Создавать собственные таблицы в установленной базе данных через веб-интерфейс приложения.
- Заполнять эти таблицы данными, которые ИИ-ассистент сможет использовать для генерации персонализированных сообщений клиентам бизнеса как в чате приложения, так и в других каналах коммуникации (например, email, Telegram и др.).
Это позволяет максимально адаптировать ИИ-ассистента под специфику вашего бизнеса и обеспечить безопасность корпоративных данных.
---
## Интерактивный обмен контентом, публикация и интеграция с ИИ
- Пользователи могут создавать и публиковать веб-страницы (о компании, продуктах, статьи) с помощью удобной формы.
- Каждая страница получает уникальный URL, оптимизирована для SEO и доступна для поиска в интернете и ИИ-системах.
- После публикации страницы можно делиться ими в корпоративном чате: появляется интерактивная карточка с кнопкой для просмотра содержимого, а также возможностью задать вопрос по содержимому страницы.
- Страницы автоматически интегрируются с RAG: разбиваются на смысловые блоки, векторизуются и используются ИИ-ассистентом для поиска и генерации ответов.
- При публикации можно выбрать интеграцию с RAG и добавить Q&A по теме страницы для последующего поиска.
- Возможна публикация страниц в соцсетях и блогах (Medium, LinkedIn, Instagram, Telegram и др.) через API с выбором платформ.
- Все компоненты реализованы с учётом безопасности и приватности данных, поддерживается удаление и редактирование страниц.
- Используются современные RAG-фреймворки (LlamaIndex, LangChain) и актуальные модели для векторизации (OpenAI, Sentence Transformers и др.).
- Страницы открыты для индексации поисковыми системами и ИИ-ботами, что обеспечивает максимальную видимость и доступность информации.
---
## Управление контактами в CRM
В разделе "Контакты" CRM администраторы приложения могут:
- Отслеживать и управлять всеми выбранными контактами через удобную таблицу.
- Использовать фильтры быстрого поиска по имени, email, Telegram, кошельку, типу контакта, дате и тегам.
- Применять кнопки быстрых действий для массовой рассылки сообщений, импорта контактов, удаления выбранных записей.
- Просматривать подробную информацию о каждом контакте, редактировать имя, email, Telegram, кошелек, язык общения.
- Добавлять и удалять теги для контакта, а также создавать новые теги прямо из интерфейса.
- Блокировать и разблокировать пользователей, полностью удалять контакт.
- Вести чат с каждым контактом прямо в интерфейсе приложения, использовать ИИ-ассистента для генерации ответов.
- Все изменения и действия с контактами доступны только администраторам.
Это позволяет эффективно управлять клиентской базой, быстро находить нужные контакты и автоматизировать коммуникации с помощью встроенных инструментов.
## Контакты и поддержка
- Email: info@hb3-accelerator.com
- Telegram: @yourproject_support
- Сайт: [https://hb3-accelerator.com](https://hb3-accelerator.com)
---
## Лицензия
MIT License
---
офер:
Привет! Я **Алекс** из венчурного фонда **HB3 Accelerator**.
Если ваша компания:
* использует **CRM** для обслуживания клиентов и имеет штат **продавцов и бухгалтеров**;
* нуждается в **безналичных платежах без лимитов, ограничений и с низкими комиссиями**;
* ценит **прозрачный учёт и свободное управление активами**,
то я буду рад предложить вам **программное обеспечение с ИИ и смарт-контрактами**.
примеры вопросов оо клиентов:
1. Как ваш софт интегрируется с уже существующими CRM-системами? Например, у нас сейчас используется 1С и Bitrix24.
2. Какие конкретно задачи автоматизирует ваш ИИ? Это только аналитика или он может, например, помогать продавцам в реальном времени?
3. Как обеспечивается безопасность и прозрачность работы со смарт-контрактами? Кто их разрабатывает и кто несёт ответственность в случае ошибки?
4. Какой у вас опыт внедрения подобных решений в СНГ? Есть ли кейсы в моей отрасли (например, розничная торговля)?
5. Какой порядок внедрения: сколько времени занимает интеграция, кто обучает персонал?
6. Какие комиссии по безналичным платежам и с какими банками/платёжными системами вы работаете?
7. Какой уровень поддержки вы предоставляете после внедрения?
ответ:
### Наше комплексное решение для вашего бизнеса
Наше программное обеспечение включает **встроенную CRM-систему**, куда вы можете легко загрузить необходимые контакты из ваших текущих CRM для **омниканального обслуживания**.
**ИИ-ассистент**, интегрированный в наш софт, способен обучаться работе с сегментами ваших клиентов и поставщиков. Обучение происходит на основе правил, которые вы устанавливаете и загружаете в **векторную базу данных**. Эта база надёжно хранит ваши конфиденциальные данные либо на **локальном**, либо на **облачном сервере**.
Мы — **молодой стартап**, представляющий свой первый технологический продукт. Внедрение нашего решения занимает от **нескольких часов до одного года**, в зависимости от сложности интеграции.
---
### Условия сотрудничества
Мы предоставляем **5 лет обновлений** для вашего программного обеспечения.
Кроме того, если **в течение первого года** мы не сможем настроить софт под индивидуальные потребности вашего бизнеса, вы можете получить **возврат 70% от стоимости**.
вопросы:
Какой у вас опыт работы с интеграцией в 1С и Bitrix24? Есть ли готовые модули или потребуется доработка под нас?
Как реализована миграция данных из старых CRM? Кто этим занимается — ваша команда или наши специалисты?
Какой стек технологий вы используете для ИИ и смарт-контрактов? На каких блокчейн-платформах строится ваша система?
Какой SLA по поддержке и реагированию на инциденты? Есть ли круглосуточная поддержка?
Как лицензируется продукт: это подписка, разовая покупка или гибридная модель?
Какой порядок оплаты: аванс, поэтапно, после внедрения?
Как вы обеспечиваете соответствие требованиям законодательства РФ/СНГ по хранению и обработке персональных данных?
Есть ли демо-доступ или пилотный проект, чтобы мы могли протестировать систему на реальных данных?
ответы :
---
### Особенности нашего решения
Мы не занимаемся интеграциями с **устаревшими CRM-системами**. Однако ваши сотрудники смогут легко выполнить **миграцию данных** при поддержке нашего **ИИ-ассистента**.
Наш **ИИ-ассистент** обеспечивает **круглосуточную поддержку**. Инциденты с нашей стороны **исключены**, поскольку приобретаемое вами программное обеспечение является **полностью локальным решением**.
---
### Безопасность и конфиденциальность данных
Хранение персональных данных **соответствует требованиям законодательства**. Все данные **зашифрованы** и хранятся **на вашей территории**.
---
### Технологический стек
Вот основные технологии, которые мы используем в нашем **backend-решении**:
* **Искусственный интеллект и машинное обучение:** `@anthropic-ai/sdk`, `@google/genai`, `@langchain/community`, `@langchain/core`, `@langchain/ollama`, `langchain`, `openai`
* **Блокчейн и смарт-контракты:** `@openzeppelin/contracts`, `ethers`, `siwe`, `viem`
* **Веб-сервер и API:** `express`, `cors`, `helmet`, `express-rate-limit`
* **Базы данных:** `pg`, `connect-pg-simple`
* **Безопасность:** `csurf`, `express-session`, `session-file-store`, `cookie`
* **Обработка электронной почты:** `imap`, `mailparser`, `nodemailer`
* **Мессенджеры:** `node-telegram-bot-api`, `telegraf`, `ws`
* **Утилиты и вспомогательные библиотеки:** `archiver`, `axios`, `cron`, `dotenv`, `multer`, `node-cron`, `semver`, `winston`
* **Инструменты разработки:** `nodemon`, `eslint`, `prettier`, `hardhat`, `mocha`, `chai`, `typescript`
вопросы:
1. Как реализована поддержка и обновления: если решение полностью локальное, как будут устанавливаться апдейты и исправления? Это делается через удалённый доступ, или вы предоставляете инструкции для нашей IT-команды?
2. Какой механизм резервного копирования и восстановления данных предусмотрен в вашем решении?
3. Если потребуется интеграция с внешними сервисами (например, платёжные шлюзы, государственные системы учёта), возможно ли это реализовать на вашей платформе?
4. Какой минимальный и рекомендуемый состав IT-специалистов нужен для поддержки вашего ПО на стороне клиента?
5. Какой порядок лицензирования используемых вами open-source библиотек и SDK? Нет ли рисков для конечного пользователя?
6. Предусмотрена ли возможность кастомизации интерфейса и бизнес-логики под наши процессы?
7. Какой минимальный объём внедрения (по стоимости или количеству пользователей) вы рассматриваете?
ответ: Конечно! Вот ответы на вопросы предпринимателя, составленные на основе документации к продукту DApp for Business:
---
### 1. Как реализована поддержка и обновления: если решение полностью локальное, как будут устанавливаться апдейты и исправления? Это делается через удалённый доступ, или вы предоставляете инструкции для нашей IT-команды?
**Ответ:**
Обновления предоставляются бесплатно в течение 5 лет. Программное обеспечение устанавливается и работает полностью локально, без необходимости постоянного интернет-доступа. Для установки обновлений вы можете использовать предоставленные bash-скрипты (`setup.sh`, `clean-logs.sh`) и инструкции из документации. При необходимости можно открыть доступ к сервису для интернет-пользователей (например, через проброс портов или прокси), но это не обязательно. Все инструкции по обновлению и обслуживанию доступны вашей IT-команде, удалённый доступ не требуется.
---
### 2. Какой механизм резервного копирования и восстановления данных предусмотрен в вашем решении?
**Ответ:**
В документации прямо не описан отдельный модуль резервного копирования, однако, поскольку все данные хранятся локально (или на вашем облачном сервере), вы полностью контролируете процесс бэкапа. Используются стандартные базы данных (`pg` — PostgreSQL), для которых легко настраиваются регулярные резервные копии с помощью штатных инструментов PostgreSQL или через Docker-скрипты. Также можно использовать bash-скрипты из папки `scripts/` для автоматизации резервного копирования и восстановления.
---
### 3. Если потребуется интеграция с внешними сервисами (например, платёжные шлюзы, государственные системы учёта), возможно ли это реализовать на вашей платформе?
**Ответ:**
Да, гибкая интеграция с внешними сервисами и кошельками поддерживается. Приложение масштабируемо и не зависит от традиционных банковских систем. В разделе "Модули" можно подключать дополнительные смарт-контракты, в том числе для приёма платежей, токенизации активов и других задач. Также реализована возможность публикации и интеграции с внешними платформами через API (например, соцсети, мессенджеры, внешние сервисы учёта).
---
### 4. Какой минимальный и рекомендуемый состав IT-специалистов нужен для поддержки вашего ПО на стороне клиента?
**Ответ:**
Для базовой эксплуатации достаточно одного системного администратора или DevOps-специалиста, знакомого с Docker, Linux и базовыми инструментами Node.js/PostgreSQL. Вся установка и обслуживание автоматизированы скриптами и не требуют глубоких знаний в программировании. Для расширенной кастомизации или интеграции с внешними сервисами может потребоваться разработчик с опытом работы с Node.js, смарт-контрактами (Solidity) и API.
---
### 5. Какой порядок лицензирования используемых вами open-source библиотек и SDK? Нет ли рисков для конечного пользователя?
**Ответ:**
Программное обеспечение распространяется по лицензии MIT, что гарантирует отсутствие ограничений для конечного пользователя. Все используемые библиотеки (например, OpenZeppelin, LangChain, Express, PostgreSQL и др.) также имеют открытые лицензии (MIT, Apache 2.0 и аналогичные), что исключает юридические риски для вашего бизнеса.
---
### 6. Предусмотрена ли возможность кастомизации интерфейса и бизнес-логики под наши процессы?
**Ответ:**
Да, архитектура приложения модульная и предусматривает возможность кастомизации. Вы можете добавлять собственные таблицы в базу данных через веб-интерфейс, подключать новые модули смарт-контрактов, настраивать параметры бизнеса, интегрировать свои ИИ-модели и дообучать их на ваших данных. Интерфейс реализован на Vue.js и может быть доработан под ваши задачи.
---
### 7. Какой минимальный объём внедрения (по стоимости или количеству пользователей) вы рассматриваете?
**Ответ:**
В документации не указаны ограничения по минимальному объёму внедрения или количеству пользователей. Продукт ориентирован как на индивидуальных предпринимателей, так и на малый и средний бизнес, стартапы и фрилансеров. Вы можете начать с одного пользователя и масштабировать решение по мере роста бизнеса.

View File

@@ -0,0 +1,16 @@
-- 048_add_order_to_user_rows.sql
-- Добавляет поле order в user_rows для поддержки сортировки строк
ALTER TABLE user_rows ADD COLUMN "order" INTEGER DEFAULT 0;
-- Проставить уникальные значения order для существующих строк (по id)
DO $$
DECLARE
r RECORD;
idx INTEGER := 1;
BEGIN
FOR r IN SELECT id FROM user_rows ORDER BY id LOOP
UPDATE user_rows SET "order" = idx WHERE id = r.id;
idx := idx + 1;
END LOOP;
END$$;

View File

@@ -93,7 +93,6 @@
"semver": "^7.7.1", "semver": "^7.7.1",
"**/utf7/semver": "^7.7.1", "**/utf7/semver": "^7.7.1",
"tar-fs": "^3.0.0", "tar-fs": "^3.0.0",
"parse-duration": "^1.1.0",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"

View File

@@ -365,6 +365,27 @@ router.patch('/:id', async (req, res, next) => {
} }
}); });
// PATCH: массовое обновление порядка строк (order)
router.patch('/:id/rows/order', async (req, res, next) => {
try {
const tableId = req.params.id;
const { order } = req.body; // order: [{rowId, order}, ...]
if (!Array.isArray(order)) {
return res.status(400).json({ error: 'order должен быть массивом' });
}
for (const item of order) {
if (!item.rowId || typeof item.order !== 'number') continue;
await db.getQuery()(
'UPDATE user_rows SET "order" = $1, updated_at = NOW() WHERE id = $2 AND table_id = $3',
[item.order, item.rowId, tableId]
);
}
res.json({ success: true });
} catch (err) {
next(err);
}
});
// Получить id колонок с purpose 'question' и 'answer' // Получить id колонок с purpose 'question' и 'answer'
async function getQuestionAnswerColumnIds(tableId) { async function getQuestionAnswerColumnIds(tableId) {
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(

View File

@@ -2,7 +2,13 @@
<template v-if="column.type === 'multiselect'"> <template v-if="column.type === 'multiselect'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span> <span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
<span v-else style="color:#bbb"></span> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div> </div>
<div v-else class="tags-cell-edit"> <div v-else class="tags-cell-edit">
<div class="tags-multiselect"> <div class="tags-multiselect">
@@ -23,7 +29,13 @@
<template v-else-if="column.type === 'relation'"> <template v-else-if="column.type === 'relation'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedRelationName">{{ selectedRelationName }}</span> <span v-if="selectedRelationName">{{ selectedRelationName }}</span>
<span v-else style="color:#bbb"></span> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div> </div>
<div v-else class="tags-cell-edit"> <div v-else class="tags-cell-edit">
<select v-model="editRelationValue" class="notion-input"> <select v-model="editRelationValue" class="notion-input">
@@ -42,13 +54,19 @@
<template v-else-if="column.type === 'multiselect-relation'"> <template v-else-if="column.type === 'multiselect-relation'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.map(prettyDisplay).join(', ') }}</span> <span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.map(prettyDisplay).join(', ') }}</span>
<span v-else>{{ prettyDisplay(localValue) }}</span> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div> </div>
<div v-else class="tags-cell-edit"> <div v-else class="tags-cell-edit">
<div class="tags-multiselect"> <div class="tags-multiselect">
<div v-for="option in multiRelationOptions" :key="option.id" class="tag-option"> <div v-for="option in multiRelationOptions" :key="option.id" class="tag-option">
<input type="checkbox" :id="'cell-multirel-' + option.id + '-' + rowId" :value="String(option.id)" v-model="editMultiRelationValues" /> <input type="checkbox" :id="'cell-multirel-' + option.id + '-' + rowId" :value="String(option.id)" v-model="editMultiRelationValues" />
<label :for="'cell-multirel-' + option.id + '-' + rowId">{{ prettyDisplay(option.display) }}</label> <label :for="'cell-multirel-' + option.id + '-' + rowId">{{ prettyDisplay(option.display, multiRelationOptions.value) }}</label>
<button class="delete-tag-btn" @click.prevent="deleteTag(option.id)" title="Удалить тег">×</button> <button class="delete-tag-btn" @click.prevent="deleteTag(option.id)" title="Удалить тег">×</button>
</div> </div>
</div> </div>
@@ -67,20 +85,33 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-if="!editing" class="cell-view-value" @click="editing = true">
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span> <span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
<input <span v-else-if="localValue">{{ localValue }}</span>
<span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div>
<textarea
v-else v-else
v-model="localValue" v-model="localValue"
@blur="save" @blur="saveAndExit"
@keyup.enter="save" @keyup.enter="saveAndExit"
:placeholder="column.name" :placeholder="column.name"
class="cell-input" class="cell-input"
autofocus
ref="textareaRef"
@input="autoResize"
/> />
</template> </template>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, computed } from 'vue'; import { ref, watch, onMounted, computed, nextTick } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
const props = defineProps(['rowId', 'column', 'cellValues']); const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
@@ -110,6 +141,27 @@ const selectedMultiRelationNames = ref([]);
const showAddTagInput = ref(false); const showAddTagInput = ref(false);
const newTagName = ref(''); const newTagName = ref('');
const textareaRef = ref(null);
function autoResize() {
const ta = textareaRef.value;
if (ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
}
watch(editing, (val) => {
if (val) {
nextTick(() => {
if (textareaRef.value) {
autoResize();
setTimeout(() => autoResize(), 0);
}
});
}
});
// Добавляем watch для отслеживания изменений в мультисвязях // Добавляем watch для отслеживания изменений в мультисвязях
watch(editMultiRelationValues, (newValues, oldValues) => { watch(editMultiRelationValues, (newValues, oldValues) => {
console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues); console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues);
@@ -472,6 +524,11 @@ function save() {
emit('update', localValue.value); emit('update', localValue.value);
} }
function saveAndExit() {
save();
editing.value = false;
}
function isArrayString(val) { function isArrayString(val) {
if (typeof val !== 'string') return false; if (typeof val !== 'string') return false;
try { try {
@@ -482,36 +539,62 @@ function isArrayString(val) {
} }
} }
function parseArrayString(val) { function parseArrayString(val) {
if (typeof val !== 'string') return [];
// Пробуем как JSON
try { try {
const arr = JSON.parse(val); const arr = JSON.parse(val);
return Array.isArray(arr) ? arr : [val]; if (Array.isArray(arr)) return arr.map(String);
} catch { } catch {}
return [val]; // Пробуем как PostgreSQL-массив
if (/^\{.*\}$/.test(val)) {
return val.replace(/[{}\s"]/g, '').split(',').filter(Boolean);
} }
// Если просто строка
if (val.trim().length > 0) return [val.trim()];
return [];
} }
function prettyDisplay(val) { function prettyDisplay(val, optionsArr) {
if (isArrayString(val)) { const arr = parseArrayString(val);
return parseArrayString(val).join(', '); if (!arr.length) return '—';
if (optionsArr && Array.isArray(optionsArr)) {
// Для relation/multiselect-relation ищу display по id
return arr.map(id => {
const found = optionsArr.find(opt => String(opt.id) === String(id) || String(opt) === String(id));
return found ? (found.display || found) : id;
}).join(', ');
} }
return val; return arr.join(', ');
} }
</script> </script>
<style scoped> <style scoped>
.cell-input { .cell-input {
width: 100%; border: none !important;
border: 1px solid #e0e0e0; outline: none !important;
border-radius: 5px; background: transparent !important;
padding: 0.3em 0.5em; box-shadow: none !important;
font-size: 1em; padding: 0 !important;
background: #fff; resize: none !important;
transition: border 0.2s; width: 100% !important;
min-height: 32px;
font: inherit;
color: inherit;
overflow: hidden;
} }
.cell-input:focus { .cell-input:focus {
border: 1.5px solid #2ecc40; border: 1.5px solid #2ecc40;
outline: none; outline: none;
} }
.tags-cell-view, .tags-cell-edit, .lookup-cell-view, .tag-option, .multi-relation-option, .add-multiselect-option, .add-tag-form, .multi-relation-options, .multi-relation-edit, .multi-relation-actions, .action-buttons {
white-space: normal !important;
word-break: break-word !important;
height: auto !important;
min-height: 1.7em;
align-items: flex-start !important;
vertical-align: top !important;
overflow: visible !important;
}
.tags-cell-view { .tags-cell-view {
min-height: 1.7em; min-height: 1.7em;
cursor: pointer; cursor: pointer;
@@ -706,4 +789,30 @@ function prettyDisplay(val) {
.add-tag-block { .add-tag-block {
margin: 0.7em 0; margin: 0.7em 0;
} }
.cell-view-value {
display: block;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
width: 100%;
cursor: pointer;
transition: background 0.15s;
min-height: 32px;
}
.cell-view-value:hover {
background: #f3f4f6;
}
.cell-plus-icon {
color: #b6c6e6;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
transition: color 0.15s;
vertical-align: middle;
}
.cell-plus-icon:hover {
color: #4f8cff;
}
</style> </style>

View File

@@ -2,16 +2,13 @@
<div class="user-table-header" v-if="tableMeta"> <div class="user-table-header" v-if="tableMeta">
<h2>{{ tableMeta.name }}</h2> <h2>{{ tableMeta.name }}</h2>
<div class="table-desc">{{ tableMeta.description }}</div> <div class="table-desc">{{ tableMeta.description }}</div>
<div class="table-header-actions" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; margin-bottom: 18px;">
<el-button type="danger" :disabled="!selectedRows.length" @click="deleteSelectedRows">Удалить выбранные</el-button>
<span v-if="selectedRows.length">Выбрано: {{ selectedRows.length }}</span>
<button v-if="isAdmin" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding"> <button v-if="isAdmin" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding">
{{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }} {{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }}
</button> </button>
<span v-if="rebuildStatus" :class="['rebuild-status', rebuildStatus.success ? 'success' : 'error']"> <el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button>
{{ rebuildStatus.message }}
</span>
</div>
<!-- Фильтры на Element Plus -->
<div class="table-filters-el" v-if="relationFilterDefs.length">
<!-- Только фильтры по multiselect-relation -->
<template v-for="def in relationFilterDefs" :key="def.col.id"> <template v-for="def in relationFilterDefs" :key="def.col.id">
<el-select <el-select
v-model="relationFilters[def.filterKey]" v-model="relationFilters[def.filterKey]"
@@ -24,54 +21,91 @@
<el-option v-for="opt in def.options" :key="opt.id" :label="opt.display" :value="opt.id" /> <el-option v-for="opt in def.options" :key="opt.id" :label="opt.display" :value="opt.id" />
</el-select> </el-select>
</template> </template>
<el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button>
</div> </div>
<span v-if="rebuildStatus" :class="['rebuild-status', rebuildStatus.success ? 'success' : 'error']">
{{ rebuildStatus.message }}
</span>
</div>
<!-- Удаляю .table-filters-el -->
<div class="notion-table-wrapper"> <div class="notion-table-wrapper">
<table class="notion-table"> <el-table
<thead> :data="filteredRows"
<tr> border
<th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)" class="th-col"> style="width: 100%"
<span v-if="!editingCol || editingCol.id !== col.id">{{ col.name }}</span> :header-cell-style="{ background: '#f3f4f6', fontWeight: 600 }"
<input v-else v-model="colEditValue" @blur="saveColEdit(col)" @keyup.enter="saveColEdit(col)" @keyup.esc="cancelColEdit" class="notion-input" /> :cell-style="{ whiteSpace: 'normal', wordBreak: 'break-word', minWidth: '80px' }"
:row-class-name="() => 'el-table-row-custom'"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="48" fixed="left" />
<el-table-column
v-for="col in columns"
:key="col.id"
:prop="'col_' + col.id"
:label="col.name"
:resizable="true"
:min-width="120"
:show-overflow-tooltip="false"
>
<template #header="{ column }">
<template v-if="editingCol && editingCol.id === col.id">
<input v-model="colEditValue" class="notion-input" style="width: 90px; display: inline-block;" @keyup.enter="saveColEdit(col)" />
<button class="save-btn" @click="saveColEdit(col)">Сохранить</button>
<button class="cancel-btn" @click="cancelColEdit">Отмена</button>
</template>
<template v-else>
<span>{{ col.name }}</span>
<button class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button> <button class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button>
<!-- Меню столбца --> </template>
<div v-if="openedColMenuId === col.id" class="context-menu" :style="colMenuStyle"> </template>
<button class="menu-item" @click="startRenameCol(col)">Переименовать</button> <template #default="{ row }">
<button class="menu-item" @click="startChangeTypeCol(col)">Изменить тип</button>
<button class="menu-item danger" @click="deleteColumn(col)">Удалить</button>
</div>
</th>
<th>
<button class="add-col" @click="showAddColModal = true">+</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredRows" :key="row.id">
<td v-for="col in columns" :key="col.id">
<TableCell <TableCell
:rowId="row.id" :rowId="row.id"
:column="col" :column="col"
:cellValues="cellValues" :cellValues="cellValues"
@update="val => saveCellValue(row.id, col.id, val)" @update="val => saveCellValue(row.id, col.id, val)"
/> />
</td> </template>
<td> </el-table-column>
<!-- Было два столбца: один для плюса, один для ⋮. Теперь объединяем: -->
<el-table-column
label=""
width="48"
align="center"
fixed="right"
class-name="add-col-header"
:resizable="false"
>
<template #header>
<button class="add-col-btn" @click="addColumn" title="Добавить столбец">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="10" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="10" y="5.5" width="2" height="11" rx="1" fill="#4f8cff"/>
<rect x="5.5" y="10" width="11" height="2" rx="1" fill="#4f8cff"/>
</svg>
</button>
</template>
<template #default="{ row }">
<button class="row-menu" @click.stop="openRowMenu(row, $event)"></button> <button class="row-menu" @click.stop="openRowMenu(row, $event)"></button>
<!-- Меню строки --> <teleport to="body">
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle"> <div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
<button class="menu-item" @click="addRowAfter(row)">Добавить строку</button>
<button class="menu-item" @click="moveRowUp(row)" :disabled="rows.findIndex(r => r.id === row.id) === 0">Переместить вверх</button>
<button class="menu-item" @click="moveRowDown(row)" :disabled="rows.findIndex(r => r.id === row.id) === rows.length - 1">Переместить вниз</button>
<button class="menu-item danger" @click="deleteRow(row)">Удалить</button> <button class="menu-item danger" @click="deleteRow(row)">Удалить</button>
</div> </div>
</td> </teleport>
</tr> </template>
<tr> </el-table-column>
<td :colspan="columns.length + 1"> </el-table>
<button class="add-row" @click="addRow">+ Добавить строку</button> <teleport to="body">
</td> <div v-if="openedColMenuId" class="context-menu" :style="colMenuStyle">
</tr> <button class="menu-item" @click="editColumn(columns.find(c => c.id === openedColMenuId))">Редактировать</button>
</tbody> <button class="menu-item danger" @click="deleteColumn(columns.find(c => c.id === openedColMenuId))">Удалить</button>
</table> <!-- <button class="menu-item" @click="addColumn">Добавить столбец</button> -->
<!-- Оверлей для закрытия меню по клику вне --> </div>
</teleport>
<div v-if="openedColMenuId || openedRowMenuId" class="menu-overlay" @click="closeMenus"></div> <div v-if="openedColMenuId || openedRowMenuId" class="menu-overlay" @click="closeMenus"></div>
<!-- Модалка добавления столбца --> <!-- Модалка добавления столбца -->
<div v-if="showAddColModal" class="modal-backdrop"> <div v-if="showAddColModal" class="modal-backdrop">
@@ -136,6 +170,19 @@ const tableMeta = ref(null);
// const selectedProduct = ref(''); // const selectedProduct = ref('');
// const productOptions = ref([]); // const productOptions = ref([]);
const filteredRows = ref([]); const filteredRows = ref([]);
const selectedRows = ref([]);
function handleSelectionChange(val) {
selectedRows.value = val;
}
async function deleteSelectedRows() {
if (!selectedRows.value.length) return;
if (!confirm(`Удалить выбранные строки (${selectedRows.value.length})?`)) return;
for (const row of selectedRows.value) {
await tablesService.deleteRow(row.id);
}
selectedRows.value = [];
await fetchTable();
}
// Для модалки добавления столбца // Для модалки добавления столбца
const showAddColModal = ref(false); const showAddColModal = ref(false);
@@ -244,6 +291,7 @@ async function handleAddColumn() {
closeAddColModal(); closeAddColModal();
await fetchTable(); await fetchTable();
await updateRelationFilterDefs(); // Явно обновляем фильтры await updateRelationFilterDefs(); // Явно обновляем фильтры
window.dispatchEvent(new Event('placeholders-updated'));
} }
async function deleteColumn(col) { async function deleteColumn(col) {
@@ -252,6 +300,7 @@ async function deleteColumn(col) {
await tablesService.deleteColumn(col.id); await tablesService.deleteColumn(col.id);
await fetchTable(); await fetchTable();
await updateRelationFilterDefs(); // Явно обновляем фильтры await updateRelationFilterDefs(); // Явно обновляем фильтры
window.dispatchEvent(new Event('placeholders-updated'));
} }
// Удаляю все переменные, функции и UI, связанные с tags, tagOptions, selectedTags, loadTags, updateFilterOptions с tags, и т.д. // Удаляю все переменные, функции и UI, связанные с tags, tagOptions, selectedTags, loadTags, updateFilterOptions с tags, и т.д.
@@ -411,6 +460,9 @@ function addColumn() {
function addRow() { function addRow() {
tablesService.addRow(props.tableId).then(fetchTable); tablesService.addRow(props.tableId).then(fetchTable);
} }
function addRowAfter(row) {
tablesService.addRow(props.tableId, row.id).then(fetchTable);
}
function openColMenu(col, event) { function openColMenu(col, event) {
openedColMenuId.value = col.id; openedColMenuId.value = col.id;
openedRowMenuId.value = null; openedRowMenuId.value = null;
@@ -438,6 +490,33 @@ async function deleteRow(row) {
await fetchTable(); await fetchTable();
} }
async function saveRowsOrder() {
// Сохраняем новый порядок строк на сервере
const orderArr = rows.value.map((row, idx) => ({ rowId: row.id, order: idx + 1 }));
await tablesService.updateRowsOrder(props.tableId, orderArr);
}
function moveRowUp(row) {
const idx = rows.value.findIndex(r => r.id === row.id);
if (idx > 0) {
const temp = rows.value[idx - 1];
rows.value[idx - 1] = rows.value[idx];
rows.value[idx] = temp;
saveRowsOrder();
fetchTable();
}
}
function moveRowDown(row) {
const idx = rows.value.findIndex(r => r.id === row.id);
if (idx < rows.value.length - 1) {
const temp = rows.value[idx + 1];
rows.value[idx + 1] = rows.value[idx];
rows.value[idx] = temp;
saveRowsOrder();
fetchTable();
}
}
async function rebuildIndex() { async function rebuildIndex() {
rebuilding.value = true; rebuilding.value = true;
rebuildStatus.value = null; rebuildStatus.value = null;
@@ -458,12 +537,12 @@ async function rebuildIndex() {
<style scoped> <style scoped>
.user-table-header { .user-table-header {
max-width: 1100px; /* max-width: 1100px; */
margin: 32px auto 0 auto; margin: 0 auto 0 auto;
padding: 32px 24px 18px 24px; /* padding: 32px 24px 18px 24px; */
background: #fff; /* background: #fff; */
border-radius: 18px; /* border-radius: 18px; */
box-shadow: 0 4px 24px rgba(0,0,0,0.08); /* box-shadow: 0 4px 24px rgba(0,0,0,0.08); */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -515,41 +594,24 @@ async function rebuildIndex() {
} }
.notion-table-wrapper { .notion-table-wrapper {
max-width: 1100px; /* max-width: 1100px; */
margin: 24px auto 0 auto; margin: 0 auto 0 auto;
background: #fff; /* background: #fff; */
border-radius: 6px; /* border-radius: 6px; */
box-shadow: 0 1px 4px rgba(0,0,0,0.04); /* box-shadow: 0 1px 4px rgba(0,0,0,0.04); */
padding: 12px 6px 18px 6px; /* padding: 12px 6px 18px 6px; */
} }
.notion-table { .el-table__cell, .el-table th, .el-table td {
width: 100%; height: auto !important;
border-collapse: collapse; min-height: 36px;
font-size: 0.98rem; white-space: normal !important;
background: #fff; word-break: break-word !important;
}
.notion-table th, .notion-table td {
border: 1px solid #e5e7eb;
padding: 6px 10px;
text-align: left;
background: #fff;
font-size: 0.98rem;
min-width: 80px; min-width: 80px;
max-width: 600px;
} }
.el-table-row-custom {
.notion-table th { /* Можно добавить стили для высоты строк, если нужно */
background: #f3f4f6;
font-weight: 600;
border-bottom: 2px solid #d1d5db;
border-top: 1px solid #e5e7eb;
padding-top: 7px;
padding-bottom: 7px;
}
.notion-table tr:hover td {
background: #f5f7fa;
} }
.notion-input { .notion-input {
@@ -602,7 +664,7 @@ async function rebuildIndex() {
.context-menu { .context-menu {
position: absolute; position: absolute;
z-index: 10; z-index: 2000;
min-width: 120px; min-width: 120px;
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
@@ -691,6 +753,25 @@ async function rebuildIndex() {
font-style: italic; font-style: italic;
} }
.add-col-header .add-col-btn {
background: none;
border: none;
padding: 0;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s;
}
.add-col-header .add-col-btn:hover svg circle {
fill: #e5e7eb;
stroke: #4f8cff;
}
.add-col-header .add-col-btn:active svg circle {
fill: #dbeafe;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.notion-table-wrapper, .table-filters-el { .notion-table-wrapper, .table-filters-el {
padding: 4px 1vw; padding: 4px 1vw;

View File

@@ -63,5 +63,10 @@ export default {
async rebuildIndex(tableId) { async rebuildIndex(tableId) {
const res = await api.post(`/tables/${tableId}/rebuild-index`); const res = await api.post(`/tables/${tableId}/rebuild-index`);
return res.data; return res.data;
},
async updateRowsOrder(tableId, orderArr) {
// orderArr: [{rowId, order}, ...]
const res = await api.patch(`/tables/${tableId}/rows/order`, { order: orderArr });
return res.data;
} }
}; };

View File

@@ -2,13 +2,98 @@
<BaseLayout> <BaseLayout>
<div class="content-page-block"> <div class="content-page-block">
<h2>Контент</h2> <h2>Контент</h2>
<p>Здесь будет размещён контент.</p> <form class="content-form" @submit.prevent>
<div class="form-group">
<label for="title">Заголовок страницы *</label>
<input v-model="form.title" id="title" type="text" required />
</div>
<div class="form-group">
<label for="summary">Краткое описание *</label>
<textarea v-model="form.summary" id="summary" required rows="2" />
</div>
<div class="form-group">
<label for="content">Основной контент *</label>
<textarea v-model="form.content" id="content" required rows="6" />
</div>
<div class="form-group">
<label for="image">Изображение/обложка</label>
<input v-model="form.image" id="image" type="text" placeholder="URL или имя файла" />
</div>
<div class="form-group">
<label for="tags">Теги</label>
<div class="tags-input">
<input
v-model="tagInput"
@keydown.enter.prevent="addTag"
@blur="addTag"
placeholder="Введите тег и нажмите Enter"
/>
<div class="tags-list">
<span v-for="(tag, idx) in form.tags" :key="tag" class="tag">
{{ tag }}
<button type="button" @click="removeTag(idx)">&times;</button>
</span>
</div>
</div>
</div>
<div class="form-group">
<label for="category">Категория</label>
<select v-model="form.category" id="category">
<option value="">Не выбрано</option>
<option value="О компании">О компании</option>
<option value="Продукты">Продукты</option>
<option value="Блог">Блог</option>
<option value="FAQ">FAQ</option>
</select>
</div>
<div class="form-group">
<label for="addToChat">Добавить в чат</label>
<select v-model="form.addToChat" id="addToChat">
<option value="yes">Да</option>
<option value="no">Нет</option>
</select>
</div>
<div class="form-group">
<label for="rag">Интегрировать с RAG</label>
<select v-model="form.rag" id="rag">
<option value="yes">Да</option>
<option value="no">Нет</option>
</select>
</div>
<button class="submit-btn" type="submit">Сохранить</button>
</form>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import BaseLayout from '../components/BaseLayout.vue'; import BaseLayout from '../components/BaseLayout.vue';
const form = ref({
title: '',
summary: '',
content: '',
image: '',
tags: [],
category: '',
addToChat: 'yes',
rag: 'yes',
});
const tagInput = ref('');
function addTag() {
const tag = tagInput.value.trim();
if (tag && !form.value.tags.includes(tag)) {
form.value.tags.push(tag);
}
tagInput.value = '';
}
function removeTag(idx) {
form.value.tags.splice(idx, 1);
}
</script> </script>
<style scoped> <style scoped>
@@ -22,4 +107,62 @@ import BaseLayout from '../components/BaseLayout.vue';
position: relative; position: relative;
overflow-x: auto; overflow-x: auto;
} }
.content-form {
display: flex;
flex-direction: column;
gap: 18px;
margin-top: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
input[type="text"], textarea, select {
border: 1px solid #d0d0d0;
border-radius: 6px;
padding: 8px 10px;
font-size: 1rem;
width: 100%;
}
.tags-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: #f0f0f0;
border-radius: 4px;
padding: 2px 8px;
display: flex;
align-items: center;
font-size: 0.95em;
}
.tag button {
background: none;
border: none;
color: #888;
margin-left: 4px;
cursor: pointer;
font-size: 1.1em;
}
.submit-btn {
background: #2d72d9;
color: #fff;
border: none;
border-radius: 6px;
padding: 10px 0;
font-size: 1.1em;
cursor: pointer;
margin-top: 12px;
transition: background 0.2s;
}
.submit-btn:hover {
background: #1a4e96;
}
</style> </style>

View File

@@ -101,7 +101,7 @@
<script setup> <script setup>
import BaseLayout from '@/components/BaseLayout.vue'; import BaseLayout from '@/components/BaseLayout.vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ref, onMounted, computed, watch } from 'vue'; import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue';
import axios from 'axios'; import axios from 'axios';
import RuleEditor from '@/components/ai-assistant/RuleEditor.vue'; import RuleEditor from '@/components/ai-assistant/RuleEditor.vue';
import SystemMonitoring from '@/components/ai-assistant/SystemMonitoring.vue'; import SystemMonitoring from '@/components/ai-assistant/SystemMonitoring.vue';
@@ -184,6 +184,12 @@ onMounted(() => {
loadLLMModels(); loadLLMModels();
loadEmbeddingModels(); loadEmbeddingModels();
loadPlaceholders(); loadPlaceholders();
// Подписка на глобальное событие обновления плейсхолдеров
window.addEventListener('placeholders-updated', loadPlaceholders);
});
onBeforeUnmount(() => {
window.removeEventListener('placeholders-updated', loadPlaceholders);
}); });
async function saveSettings() { async function saveSettings() {
await axios.put('/settings/ai-assistant', settings.value); await axios.put('/settings/ai-assistant', settings.value);

View File

@@ -0,0 +1,199 @@
# Интерактивный обмен веб-страницами с ИИ-ассистентом в корпоративном чате
## Описание задачи
Реализовать функционал, позволяющий пользователям создавать веб-страницы с данными о компании, продуктах или статьями (блог), делиться ими в корпоративном чате в виде интерактивных сообщений с кнопками, а также обеспечивать автоматическое добавление этих страниц в базу знаний (RAG) для поиска и генерации ответов ИИ-ассистентом. Страницы должны быть доступны для поиска в интернете по ключевым словам.
---
## Основные требования
1. **Создание веб-страниц**
- Форма на странице `/content` для ввода информации (название, описание, текст, теги, изображения и т.д.).
- Возможность предпросмотра и редактирования страницы до публикации.
- Кнопка "Поделиться", после нажатия которой страница сохраняется и становится доступной для дальнейших действий.
2. **Публикация и доступность**
- После публикации страница доступна по уникальному URL (например, `/content/page/123` или `/content/page/название`).
- Страница оптимизирована для SEO (мета-теги, ЧПУ-адреса, sitemap.xml).
- При необходимости — настройка индексации для поисковых систем (robots.txt).
- **Оптимизация для ИИ-поиска**: страницы должны быть структурированы и открыты для индексации поисковыми ИИ (Perplexity, GPT, Gemini и др.). Рекомендуется:
- Использовать семантическую разметку (schema.org, JSON-LD)
- Добавлять структурированные данные (FAQ, Article, Product)
- Открывать страницы для crawler-ботов ИИ (не блокировать их в robots.txt)
- Обеспечивать чистый, легко читаемый HTML-код
- Добавлять релевантные ключевые слова и теги
3. **Интеграция с корпоративным чатом**
- После публикации пользователь может отправить страницу в чат с помощью кнопки "Поделиться в чат".
- В чате появляется интерактивное сообщение (карточка) с краткой информацией о странице и кнопкой (например, "Показать содержимое").
- При нажатии на кнопку ассистент отправляет содержимое страницы (или его резюме) в чат.
- Возможность задать вопрос по содержимому страницы через чат (ассистент ищет ответ только в этой странице).
- При публикации страницы в чатах пользователей (веб, Telegram, email) вместе с ответами на сообщения должно автоматически добавляться интерактивное сообщение с заголовком страницы и кнопкой "Подробнее". При нажатии на кнопку ИИ-ассистент отправляет содержимое веб-страницы в чат.
4. **Интеграция с RAG (Retrieval-Augmented Generation)**
- После публикации страница автоматически разбивается на смысловые блоки (например, по абзацам).
- Каждый блок векторизуется (создается embedding) и сохраняется в векторное хранилище (Qdrant, Pinecone и т.д.) с метаданными (id страницы, теги, автор, дата).
- Ассистент использует эти данные для поиска и генерации ответов на вопросы пользователей.
- В форме создания страницы добавить выпадающий список "Интегрировать с RAG" (Да/Нет). При выборе "Да" ИИ-ассистент анализирует содержимое страницы, автоматически предлагает список возможных вопросов и ответов (Q&A) по теме страницы. Пользователь может отредактировать эти вопросы и ответы, отметить чекбоксами нужные и нажать "Добавить" — выбранные Q&A будут сохранены в базу знаний для последующего поиска и генерации ответов.
5. **Поиск в интернете**
- Страницы доступны для индексации поисковыми системами.
- Поиск по ключевым словам приводит к отображению соответствующих страниц.
---
## Пользовательские сценарии
1. **Создание и публикация страницы**
- Пользователь заполняет форму на `/content`, нажимает "Поделиться".
- Страница сохраняется, появляется уникальный URL.
2. **Обмен в чате**
- Пользователь нажимает "Поделиться в чат".
- В чате появляется карточка с кнопкой "Показать содержимое".
- Другой пользователь нажимает кнопку — ассистент отправляет содержимое страницы в чат.
3. **Поиск и ответы ассистента**
- Пользователь задаёт вопрос по теме страницы.
- Ассистент ищет ответ в RAG по содержимому страницы и формирует релевантный ответ.
4. **Поиск через интернет**
- Внешний пользователь находит страницу через поисковик по ключевым словам.
---
## Техническая реализация
### Frontend
- Vue.js, локальные scoped-стили
- Форма создания/редактирования страницы
- Компонент карточки для чата с интерактивной кнопкой
### Backend
- Node.js/NestJS/Fastify/Express
- REST API для создания, публикации, получения страниц
- Векторизация и интеграция с векторным хранилищем (Qdrant, Pinecone)
- Генерация и отправка сообщений в чат
### Векторное хранилище (RAG)
- Сохранение embedding-блоков с метаданными
- Поиск по embedding для генерации ответов ассистентом
### SEO и индексация
- Генерация мета-тегов, sitemap.xml
- ЧПУ-адреса страниц
- Настройка robots.txt
---
## Диаграмма взаимодействия
```mermaid
sequenceDiagram
participant User
participant Frontend
participant Backend
participant VectorDB
participant Chat
participant SearchEngine
User->>Frontend: Заполняет форму на /content
Frontend->>Backend: POST /api/content (данные)
Backend->>Backend: Создает страницу, сохраняет в БД
Backend->>VectorDB: Векторизация, добавление в RAG
Backend->>Frontend: Возвращает ссылку на страницу
Frontend->>Chat: Отправляет карточку в чат
Chat->>User: Показывает карточку + ответ ассистента
SearchEngine->>Backend: Индексирует страницу (если разрешено)
```
---
## Примечания
- Все компоненты должны быть реализованы с учетом безопасности и приватности данных.
- Необходимо предусмотреть возможность удаления и редактирования страниц.
- Для интеграции с ИИ-ассистентом использовать современные RAG-фреймворки (LlamaIndex, LangChain и т.д.).
- Для векторизации использовать актуальные модели (OpenAI, Sentence Transformers и др.).
---
## Автоматический выбор и добавление интерактивных сообщений ИИ-ассистентом
ИИ-ассистент может самостоятельно анализировать контекст диалога и принимать решение о добавлении интерактивных сообщений (карточек) в чат, чтобы повысить релевантность и удобство взаимодействия.
### Принцип работы
1. **Анализ контекста диалога**
- Ассистент анализирует сообщения пользователя, определяет тему, намерение и ключевые слова.
- Выполняет поиск по базе знаний (RAG) для нахождения релевантных страниц или блоков.
2. **Принятие решения о вставке интерактивного сообщения**
- Если найден релевантный контент, ассистент решает, нужно ли добавить карточку с кнопкой (например, "Подробнее").
- Решение зависит от:
- Тематики и повторяемости вопросов
- Новизны или важности контента
- Явных триггеров в сообщениях пользователя (например, "покажи инструкцию", "расскажи подробнее" и т.д.)
3. **Формирование интерактивного сообщения**
- Ассистент формирует карточку с заголовком, кратким описанием и кнопкой (например, "Подробнее").
- При нажатии на кнопку пользователем ассистент отправляет содержимое страницы или выбранного блока в чат.
### Пример сценария
- Пользователь: "Как подключить наш продукт X?"
- Ассистент:
1. Находит в RAG инструкцию по подключению продукта X.
2. Отвечает кратко и автоматически добавляет интерактивную карточку с кнопкой "Показать инструкцию".
3. При нажатии на кнопку отправляет подробную инструкцию в чат.
### Преимущества
- Пользователь получает релевантную информацию в нужный момент, не перегружая чат лишними карточками.
- Ассистент становится более "умным" и проактивным.
- Повышается вовлечённость и удовлетворённость пользователей.
---
## Публикация страниц в социальных сетях и блогах через API
Система может поддерживать публикацию созданных страниц на страницах компании в социальных сетях и блогах (Medium, LinkedIn, Instagram, Paragraph, Telegraph, Telegram и др.) с помощью соответствующих API.
### Возможности
- Публикация статей и постов на платформах: Medium, LinkedIn (страницы компаний), Instagram (бизнес-аккаунты), Paragraph, Telegraph, Telegram (боты/каналы) и др.
- Выбор платформ для публикации пользователем (чекбоксы или список).
- Формирование контента с учётом особенностей каждой платформы (текст, изображения, разметка).
### Принцип работы
1. Пользователь выбирает опцию "Опубликовать в соцсетях" после создания страницы.
2. Открывается окно выбора платформ и авторизации (OAuth2, если требуется).
3. Для каждой платформы формируется подходящий формат контента.
4. Система отправляет запросы к API выбранных платформ для публикации.
5. Пользователь получает уведомление об успешной публикации или ошибке.
### Ограничения и нюансы
- Для некоторых платформ требуется бизнес-аккаунт и прохождение модерации приложения (LinkedIn, Instagram).
- Формат и объём контента может отличаться (например, Instagram — только изображение+текст).
- Возможны лимиты на частоту публикаций и размер постов.
- Некоторые платформы могут задерживать публикацию для модерации.
### Пример архитектуры
```mermaid
sequenceDiagram
participant User
participant Frontend
participant Backend
participant SocialAPI
User->>Frontend: Нажимает “Опубликовать в соцсетях”
Frontend->>Backend: POST /api/publish-to-social (данные, токены)
Backend->>SocialAPI: Публикует на выбранных платформах
SocialAPI-->>Backend: Ответ (успех/ошибка)
Backend-->>Frontend: Сообщение о результате
Frontend-->>User: Уведомление
```
### Рекомендации
- Для каждой платформы реализовать отдельный модуль интеграции или использовать сторонние сервисы (Zapier, Make).
- Предусмотреть очередь публикаций и логи для отслеживания статуса.
- Обеспечить безопасное хранение и обновление токенов авторизации.