Author SHA1 Message Date
Codex 33fe774e1a docs: дополнить changelog build 0.38 2026-08-05 21:48:02 +03:00
Codex 7098eb9d6e AVE.cms 3.3 build 0.38 2026-08-05 21:40:21 +03:00
Codex 918c989f91 AVE.cms 3.3 build 0.37 2026-07-30 16:34:14 +03:00
283 changed files with 15270 additions and 851 deletions
+3
View File
@@ -24,8 +24,11 @@
/storage/backups/
/storage/importprice/
/storage/imports/
/storage/jobs/
/storage/logs/
/storage/media-trash/
/storage/releases/
/storage/reports/
/storage/runtime/
/storage/secrets/
/storage/tmp/
+283
View File
@@ -0,0 +1,283 @@
# История изменений AVE.cms
Здесь собраны заметные для пользователей изменения AVE.cms 3.3, начиная со
сборки 0.30. Внутренние рефакторинги, тестовые файлы и служебные изменения
сборщика перечислены только тогда, когда они влияют на установку, обновление
или поведение системы.
Часть возможностей распространяется отдельными модулями. Такие изменения
помечены как изменения каталога модулей и не означают, что модуль автоматически
установлен вместе с чистым ядром.
## Не выпущено
Новые изменения будут добавлены после build 0.38.
## 3.3 build 0.38 — 5 августа 2026
### Поля документов
- Описание поля снова поддерживает безопасную HTML-разметку и показывается
редактору под полем документа.
- Для подсказок можно использовать выделение, уменьшенный текст, переносы,
ссылки и списки. Скрипты и опасные HTML-атрибуты автоматически удаляются.
### Пользователи и безопасность
- В карточке пользователя появились активные сеансы с возможностью завершить
отдельный сеанс или все остальные устройства.
- Неудачные попытки входа записываются в аудит и доступны для контроля.
- Поддержан временный пароль с обязательной сменой при следующем входе.
- Усилены проверки API-токенов, внешних адресов, возврата после авторизации и
публичных форм.
- Антиспам получил автоматические блокировки и уведомления о всплесках.
### Медиа и публичное оформление
- Медиатека получила безопасную корзину, анализ использования файлов и
восстановление удалённых объектов.
- В редакторе документа можно заменить изображения его папки без отдельного
перехода в медиатеку.
- Исправлена очистка и повторная генерация миниатюр.
- Публичные представления модулей доступны для редактирования в активной теме
с проверкой шаблона, ревизиями и возвратом к поставке модуля.
### Поиск и работа с контентом
- Поиск документов и товаров учитывает русскую и английскую запись, неверную
раскладку, формы слов и приоритет точного совпадения.
- Модуль поиска получил управляемые синонимы, области выдачи и редактируемые
публичные шаблоны.
- Массовый редактор документов безопасно обновляет только выбранные поля и
корректно инвалидирует публичный кеш.
- Улучшены быстрые переходы из публичного режима редактирования к реальным
разделам панели.
### Модули
- A/B-тесты показывают достаточность выборки и статистическую достоверность
результата, а не только проценты конверсии.
- Рейтинги получили просмотр и модерацию отдельных голосов.
- Задачи Todo и Канбан поддерживают сроки, исполнителей, видимость и связь с
документами.
- Всплывающие окна получили защиту от повторного учёта событий и уведомления.
- В публичный каталог добавлены кросспостинг, импорт документов и безопасное
сравнение локальной и рабочей систем.
- Встроенные разделы панели, включая медиатеку, больше не показываются как
отдельные устанавливаемые модули.
- Из публичного каталога исключены закрытые и проектные расширения, инструменты
разработчика, товарный контур и служебные помощники выпуска.
### Выпуск и диагностика
- Помощник выпуска проверяет миграции отдельно устанавливаемых модулей отдельно
от миграций чистого ядра.
- Мониторинг здоровья, планировщик и каталоги обновлений получили дополнительные
уведомления о требующих внимания состояниях.
- Для build 0.38 опубликованы чистая core-сборка, установщик и накопительное
обновление с build 0.20.
- Чистая сборка по-прежнему не содержит торговые, проектные и закрытые модули.
## 3.3 build 0.37 — 30 июля 2026
### Медиа
- Добавлены именованные виды изображений: администратор может заранее задать
назначение, размеры и способ кадрирования изображения.
- В редакторе изображения появилось действие «Подогнать под вид».
- Для вида изображения можно создавать WebP-двойник.
### Рубрики
- Удаление рубрики стало обратимым: удалённые рубрики помещаются в корзину.
- Из корзины рубрику можно восстановить или удалить окончательно.
- Ревизии схемы рубрики переведены на общий механизм хранения истории.
### Пользователи
- Глобальный поиск теперь находит пользователей панели.
- В таблицу пользователей добавлена дата последнего входа.
- Поиск пользователей корректно работает с кириллицей.
- Защищён текущий публичный аккаунт: пользователь не может случайно отключить
или удалить собственную учётную запись во время работы.
- Для публичных аккаунтов добавлено управляемое удаление с проверкой прав и
защитой связанных данных.
### Исправления
- Начальная установка создаёт таблицы для видов изображений.
- Улучшена совместимость панели каталога, рубрик и тем с модульными ресурсами.
## 3.3 build 0.36 — 30 июля 2026
### Каталог
- Редактор разделов каталога получил наглядное древовидное представление.
- Порядок и вложенность разделов можно менять перетаскиванием.
- Исправлено выделение активного раздела и обновление дерева после операций.
- Улучшена работа с большими деревьями и длинными названиями разделов.
### Товарные фиды
- Переработан интерфейс создания и редактирования фидов.
- Добавлены понятные настройки источника, разделов, полей и правил выгрузки.
- Улучшены проверка конфигурации и формирование публичного файла фида.
- Модуль товарных фидов поставляется отдельно через каталог модулей.
## 3.3 build 0.35 — 30 июля 2026
### Поиск
- Добавлен единый механизм провайдеров глобального поиска. Разделы и модули
могут добавлять собственные типы результатов без изменения поискового ядра.
- Поиск документов учитывает раскладку клавиатуры, транслитерацию, части слов
и приоритет совпадения.
- Результаты получили единый формат заголовков, пояснений, ссылок и иконок.
### Диагностика контента
- Добавлена безопасная интроспекция страницы: система может определить
используемые документы, рубрики, запросы, блоки, навигацию и представления.
- Диагностика сообщает о недостающих связях и шаблонах без выполнения
пользовательского PHP-кода.
- Публичный отладчик получил более точные сведения о составе страницы.
### Ревизии
- Создан общий механизм JSON-ревизий для административных сущностей.
- Блоки, шаблоны, рубрики и представления используют единый формат снимков,
сравнения и восстановления.
### Интеграции и безопасность
- Исходящие HTTP-запросы получили единый журнал с временем выполнения,
результатом и безопасно очищенными диагностическими данными.
- Усилена проверка внешних адресов и защита от обращения к запрещённым сетевым
ресурсам.
- Старые MD5-пароли публичных пользователей автоматически заменяются
современным хешем после успешного входа.
- Усилена проверка локальных адресов возврата после авторизации.
- API-токены получили более строгую проверку областей доступа.
### Модули
- Планировщик поддерживает длительные задания, разбитые на небольшие шаги.
- Поиск и похожие документы могут выполнять переиндексацию через планировщик.
- В каталог модулей добавлен MCP-мост для управляемого доступа внешних
инструментов к разрешённым данным AVE.cms.
- Для платёжных модулей и модуля магазина добавлен журнал обращений и проверка
фактического статуса платежа. Эти возможности не входят в чистое ядро.
## 3.3 build 0.34 — 29 июля 2026
### Модель контента
- Справочники стали отдельной сущностью для повторно используемых значений
полей без создания лишних публичных документов.
- Добавлены двусторонние связи документов.
- Добавлены вычисляемые поля.
- Появилась библиотека готовых наборов полей для быстрого создания рубрик.
- Визуальная карта сайта показывает основные сущности и их связи.
### Управление
- Добавлена командная палитра и глобальный административный поиск.
- Аудит действий стал нагляднее: события проще фильтровать и просматривать по
времени и пользователям.
- Улучшена страница диагностики и готовности сайта.
### Представления
- Для публичных представлений добавлены ревизии и безопасный предпросмотр.
- Модуль A/B-тестов умеет проверять разные варианты представлений.
### Надёжность
- Добавлен модуль надёжных исходящих событий: неотправленные события
сохраняются, повторяются и доступны в журнале.
- Добавлен модуль автоматического прогрева кеша.
- Миграция из старых версий получила полную и выборочную схемы переноса,
предварительный анализ таблиц и пошаговый прогресс.
## 3.3 build 0.33 — 28 июля 2026
### Публичный сайт
- Добавлен раздел управления публичным сайтом со структурой, размещениями и
представлениями.
- Представление можно назначить документу, рубрике, запросу или другому
поддерживаемому источнику без жёсткой привязки разметки в PHP-коде.
- Добавлены предпросмотр и история изменений представлений.
- Карта размещений показывает, какой компонент и шаблон формируют конкретную
часть страницы.
### Контент
- Документы, рубрики, запросы, блоки и навигация получили единые контракты
сохранения и вывода.
- Зависимости тегов контента учитываются при обновлении и очистке кеша.
- Выбор документов вынесен в общий репозиторий и используется разными
редакторами панели.
- Улучшено объяснение условий запросов и предпросмотр результата.
### Совместимость
- Нативная система представлений добавлена без принудительной замены
существующих шаблонов публичного сайта.
- Модули поиска, похожих документов, галерей, комментариев и других публичных
компонентов адаптированы к общему механизму вывода.
## 3.3 build 0.32 — 27 июля 2026
### Чистое ядро
- Проектные настройки и оформление конкретного сайта отделены от публичной
core-сборки.
- Локальные примеры и тестовые данные исключены из Git и релизных архивов.
- В чистой сборке сохраняются только ядро, установщик, базовая тема и
системная документация.
### Разработка
- Исходный PHP-код приведён к единому стилю AVE.cms с проверкой совместимости
с PHP 7.3.
- Добавлены правила для безопасного хранения runtime-каталогов и локальных
файлов после установки.
## 3.3 build 0.31 — 27 июля 2026
### Установка модулей
- Архивы модулей публикуются вместе с корректным описанием зависимостей.
- Установщик пакета проверяет и устанавливает необходимые зависимости в
правильном порядке.
### Обновления
- Резервная копия перед обновлением ограничивается таблицами, которых
действительно касается миграция.
- Исправлена область резервирования миграций пользователей и сохранённых
представлений.
- Обновлено описание чистой публичной сборки и порядка установки.
## 3.3 build 0.30 — 27 июля 2026
### Языки панели
- Панель управления получила русскую и английскую локализацию.
- Язык можно выбрать при установке и позднее изменить в профиле.
- Переведены системные разделы, сообщения, элементы интерфейса и клиентские
уведомления.
### Справка
- Добавлена английская версия пользовательской справки по установке, полям,
контенту, API, модулям, хукам, отладке и работе с базой данных.
- Справка открывается на выбранном языке панели.
### Установщик и пакеты
- Мастер установки стал локализованным.
- Пакеты демонстрационных сайтов могут объявлять необходимые модули и
устанавливать их как зависимости.
- Инструменты сборки проверяют языковые файлы и состав модульных архивов.
+359 -209
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
# AVE.cms
**AVE.cms 3.3 build 0.35** — модульная система управления сайтами на PHP.
**AVE.cms 3.3 build 0.37** — модульная система управления сайтами на PHP.
Она подходит для проектов, в которых структуру содержимого нужно собирать из
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
привязываясь к заранее заданному типу сайта.
+2 -1
View File
@@ -17,6 +17,7 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Permission;
use App\Common\Language;
/**
* Реестр провайдеров административного поиска.
@@ -140,7 +141,7 @@
return array(
'type' => self::code(isset($row['type']) ? $row['type'] : $providerCode),
'group' => self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80),
'group' => Language::translateSource(self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80)),
'title' => $title,
'subtitle' => self::text(isset($row['subtitle']) ? $row['subtitle'] : '', 300),
'url' => $url,
+15 -8
View File
@@ -83,6 +83,7 @@
self::item('#^/public-site/presentations$#', 'Представления', 'Создаёт повторно используемое оформление карточек, списков, слайдеров и других результатов.', array('Черновик проверяется на реальном материале и не меняет публичный сайт.', 'Публикация фиксирует версию, а назначение выбирает место и режим применения.', 'Представление меняет внешний вид готовых данных, но не заменяет рубрику, запрос, блок или модуль.'), 'Для совместимости каждое новое назначение начинает работу в режиме «Текущий вывод».', 'content/public-site'),
self::item('#^/public-site/map$#', 'Визуальная карта сайта', 'Показывает деревья меню, связанные страницы и опубликованные документы вне навигации.', array('Уровни строятся из действующих пунктов навигации.', 'Ссылка документа открывает его штатный редактор.', 'Документ вне меню может использоваться запросом, каталогом или прямой ссылкой.'), 'Карта является обзором и ничего не публикует.', 'content/public-site'),
self::item('#^/public-site/placements$#', 'Размещения компонентов', 'Показывает, в каких сохранённых шаблонах используются блоки, подборки, навигации и модульные теги.', array('Вкладка «Размещения» показывает каждое найденное место отдельно.', 'Вкладка «Где используется» объединяет одинаковые компоненты и показывает число связей.', 'Кнопки справа открывают конкретный исходный шаблон и конкретный подключённый компонент.'), 'Карта предназначена для поиска связей и ничего не меняет на публичном сайте.', 'content/public-site'),
self::item('#^/public-site/templates$#', 'Шаблоны публичного сайта', 'Редактирует общие файловые Twig-компоненты активной темы.', array('Список материалов имеет системный резерв.', 'Оболочка страницы и остальные компоненты темы редактируются без поиска файла вручную.', 'Сохранение проверяет Twig, создаёт ревизию и очищает публичный кеш.'), 'Шаблоны рубрик, запросов и блоков остаются в собственных разделах.', 'content/public-site'),
self::item('#^/public-site(?:/.*)?$#', 'Публичный сайт', 'Показывает, из каких базовых компонентов собирается сайт и где используется каждый из них.', array('Раздел является картой и не дублирует редакторы шаблонов, рубрик, запросов, блоков и меню.', 'Представления — дополнительный способ оформить уже подготовленные данные.', 'Диагностика сообщает только о явных пробелах в обязательных настройках.'), 'Начните со вкладки «Структура», а содержимое меняйте в штатном редакторе нужного компонента.', 'content/public-site'),
self::item('#^/catalog/products/shipping$#', 'Доставка и упаковка', 'Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа.', array('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'),
@@ -91,6 +92,7 @@
self::item('#^/catalog/variant-groups(?:/.*)?$#', 'Группы вариантов', 'Объединяет отдельные товары с собственными URL и артикулами в одну карточку публичного листинга.', array('Основной вариант представляет группу до выбора цвета или исполнения.', 'Порядок вариантов определяет порядок переключателей на публичной карточке.', 'Выключенный вариант остаётся документом, но не предлагается покупателю.'), 'Каждый вариант должен сохранять собственные цену, остаток, URL и артикул.'),
self::item('#^/catalog/card-templates$#', 'Карточки товаров', 'Управляет Twig-разметкой и CSS товарной карточки отдельно для разных мест публичного сайта.', array('Черновик можно проверять на живых данных товара без публикации.', 'Контексты назначают представление главной странице, каталогу и другим листингам.', 'Ревизии сохраняют историю черновиков и опубликованных версий.'), 'Публичный сайт меняется только после публикации представления и включения нативного режима нужного контекста.'),
self::item('#^/catalog/filter-templates$#', 'Шаблоны фильтров', 'Управляет Twig-разметкой, CSS и клиентским поведением фасетов товарного каталога.', array('Алгоритм выборки остаётся в каталоге, а шаблон отвечает за представление.', 'Черновик можно проверить до публикации.', 'Теги шаблона выводят название фильтра, варианты и рассчитанные количества товаров.'), 'После публикации проверьте выбор, сброс и повторное применение нескольких фильтров.'),
self::item('#^/catalog/public-templates$#', 'Публичные шаблоны товаров', 'Редактирует разметку страницы товара, вариантов, рекомендаций и комплектов в активной теме.', array('Если override отсутствует, редактор показывает резервный шаблон модуля.', 'Первое сохранение создаёт файл в активной теме и подключает пространство products_public.', 'Список данных под редактором объясняет доступные Twig-переменные и вставляет их в код.'), 'Сохранение меняет публичный сайт сразу; предыдущие версии остаются в ревизиях темы.', 'content/catalog'),
self::item('#^/catalog/\d+/\d+$#', 'Конструктор каталога', 'Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами.', array('Разделы задают структуру каталога и набор доступных полей.', 'Условия фильтров определяют SQL-ограничения публичной выборки.', 'Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу.'), 'Изменения структуры влияют на редактор документов и публичный каталог.'),
self::item('#^/catalog#', 'Каталог', 'Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами.', array('Каждый каталог создаётся для конкретного поля рубрики типа catalog.', 'Товары являются документами и не дублируются в отдельной основной таблице.', 'Для вариантов одного товара используются группы вариантов.'), 'Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры.', 'content/catalog'),
@@ -103,16 +105,21 @@
self::item('#^/navigation#', 'Навигация', 'Создаёт меню сайта: шаблоны уровней, пункты, вложенность, условия показа и связи с документами.', array('Каждая навигация имеет собственный тег для вставки в шаблон.', 'Пункты можно сортировать и переносить между уровнями.', 'Связанный документ позволяет не вводить URL вручную.'), 'После изменения структуры очищается кеш соответствующей навигации.', 'content/navigation'),
self::item('#^/requests#', 'Запросы', 'Формирует выборки документов по рубрикам и полям, а затем выводит их через шаблоны элементов и списка.', array('Условия определяют, какие документы попадут в результат.', 'Основной и постраничный шаблоны отвечают только за представление.', 'External/AJAX-флаги разрешают прямой вызов запроса и требуют осторожности.'), 'Проверяйте запрос после изменения условий: он может использоваться сразу в нескольких страницах.', 'content/requests'),
self::item('#^/media#', 'Медиа и файлы', 'Управляет файлами в uploads: загрузкой, папками, изображениями, превью и конвертацией WebP.', array('Сетка и список показывают одни и те же физические файлы.', 'Редактор изображений создаёт производные файлы для crop и resize.', 'При удалении исходника удаляются его превью и связанная WebP-копия.'), 'Не размещайте служебные PHP-файлы в uploads; раздел предназначен только для пользовательских медиа.', 'media'),
self::item('#^/certificates(?:/.*)?$#', 'Сертификаты', 'Хранит документы соответствия и связывает их с товарами или другими материалами.', array('Срок действия помогает заранее находить документы, которые требуют проверки.', 'Файл выбирается из медиатеки и остаётся доступен независимо от карточки связи.', 'Связанные товары используют один сертификат без дублирования файла.'), 'Истёкший срок снимает отметку о действующем сертификате, но сам товар автоматически не удаляет.'),
self::item('#^/ipra(?:/.*)?$#', 'ИПРА', 'Управляет программами реабилитации и связанными назначениями для работы с клиентами.', array('Запись связывается с пользователем сайта и хранит рабочие сроки.', 'Статус отражает текущий этап обработки, а не публичность документа.', 'Перед изменением назначения проверьте связанные обращения и заказы.'), 'Медицинские и персональные данные должны быть доступны только ролям, которым они необходимы.'),
self::item('#^/registrations(?:/.*)?$#', 'Регистрационные удостоверения', 'Ведёт реестр РУ и связи с товарами, чтобы показывать актуальный признак регистрации.', array('Один номер можно связать с несколькими товарами.', 'Дата окончания используется для контроля актуальности отметки.', 'Файл удостоверения выбирается из общей медиатеки.'), 'Окончание срока убирает признак действующего РУ; доступность товара определяется отдельно.'),
self::item('#^/service(?:/.*)?$#', 'Сервисное обслуживание', 'Хранит сервисные обращения, сроки, ответственных и историю работ по оборудованию.', array('Карточка объединяет клиента, оборудование и текущее состояние обращения.', 'Смена статуса фиксирует этап работы и остаётся в истории.', 'Сроки помогают находить просроченные обращения.'), 'Не удаляйте завершённые обращения, если история нужна для гарантийного обслуживания.'),
self::item('#^/system/release-assistant(?:/.*)?$#', 'Помощник выпуска', 'Проверяет состав локального релиза, версии модулей, миграции и готовность архивов перед ручной публикацией.', array('Предварительная проверка ничего не публикует и не меняет рабочий сайт.', 'В отчёте отдельно показаны изменённые файлы, таблицы и версии модулей.', 'Сборку запускают только после устранения ошибок проверки.'), 'Раздел предназначен для владельца исходного проекта и не включается в обычную публичную сборку.'),
self::item('#^/system/orders#', 'Заказы и корзина', 'Обрабатывает заказы, состояния оплаты и доставки, шаблоны корзины и брошенные сессии покупателей.', array('Карточка заказа фиксирует товары, цены и выбранные варианты на момент оформления.', 'Способы оплаты настраиваются во вкладке «Оплата», реквизиты провайдеров — во вкладке «Gateways».', 'Брошенные корзины очищаются по сроку хранения и доступны для диагностики.'), 'Отмена и возврат могут влиять на платёжный шлюз — проверяйте итоговый статус операции.'),
self::item('#^/system/customers#', 'Пользователи сайта', 'Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля.', array('Email-регистрация, восстановление доступа и создание аккаунта после заказа входят в ядро.', 'Дополнительные поля независимо подключаются к регистрации и личному кабинету.', 'Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа.', 'Отключение аккаунта запрещает вход, но сохраняет связанные данные.'), 'Email остаётся основным идентификатором; дополнительные способы входа поставляются отдельными модулями.'),
self::item('#^/system/customers#', 'Пользователи сайта', 'Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля.', array('Регистрацию можно разрешить по email, телефону или обоими способами.', 'Телефонный аккаунт не требует email; адрес можно добавить и подтвердить позже.', 'Дополнительные поля независимо подключаются к регистрации и личному кабинету.', 'Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа.', 'Отключение аккаунта запрещает вход, но сохраняет связанные данные.'), 'Для регистрации по телефону установите и настройте SMS-провайдер; без него телефонная форма не показывается.'),
self::item('#^/content/contacts/forms/\d+$#', 'Конструктор формы', 'Настраивает поля, шаблон формы, получателей, письмо и поведение после отправки.', array('Теги полей доступны в шаблоне формы и письма.', 'Получатели задаются по одному на строку.', 'История обращений хранит отправленные значения и ответы менеджеров.'), 'После изменения шаблона отправьте тестовое обращение и проверьте письмо.'),
self::item('#^/content/contacts#', 'Формы и обращения', 'Управляет публичными формами обратной связи и журналом сообщений посетителей.', array('Форма состоит из полей, шаблонов и почтовых настроек.', 'История позволяет менять статус обращения и отвечать посетителю.', 'Копирование формы создаёт независимый набор настроек.'), 'Проверяйте адреса получателей и настройки системной почты перед публикацией формы.'),
self::item('#^/content/rss#', 'RSS-каналы', 'Публикует документы выбранной рубрики в XML-ленте по настраиваемому адресу /rss/{alias}.', array('Заголовок и описание можно брать из полей рубрики.', 'Количество элементов ограничивает размер выдаваемой ленты.', 'Префикс /rss/ зарезервирован и не может использоваться документами.'), 'После изменения алиаса обновите внешние подписки и проверьте XML из списка.'),
self::item('#^/content/feeds#', 'Товарные фиды', 'Формирует управляемые XML/YML-выгрузки товаров для партнеров и торговых площадок.', array('Категории определяют товарную выборку, исключения применяются после дочерних разделов.', 'Поля XML и характеристики связываются с индексом каталога или полями товарной рубрики.', 'Предпросмотр не меняет кеш, а пересоздание атомарно публикует новый файл.'), 'Не меняйте закрепленные legacy URL у действующих партнеров; редактируйте связанную конфигурацию фида.'),
self::item('#^/content/price-import#', 'Импорт цен', 'Обновляет цены товаров из XLSX после обязательного предварительного просмотра изменений.', array('Сначала загрузите файл или получите его по настроенной HTTPS-ссылке.', 'Выберите лист и сформируйте предварительный просмотр.', 'Применение изменяет только подтверждённые строки и записывает результат в историю.'), 'Не применяйте импорт, если предварительный просмотр показывает неожиданные товары или пустые цены.'),
self::item('#^/users#', 'Пользователи', 'Управляет сотрудниками, которые могут входить в панель управления, их ролями и активностью.', array('Роль определяет доступные разделы и действия.', 'Отключённый пользователь не может войти, но сохраняется в журнале действий.', 'Нельзя отключить или удалить собственную активную учётную запись.'), 'Публичные аккаунты находятся в отдельном разделе «Пользователи сайта».', 'administration/users'),
self::item('#^/users#', 'Пользователи', 'Управляет сотрудниками, которые могут входить в панель управления, их ролями, паролями и активными сессиями.', array('Роль определяет доступные разделы и действия.', 'Во вкладке «Сессии и входы» видны устройства, IP и неудачные попытки входа.', 'Временный пароль можно потребовать сменить при следующем входе.', 'Отключение учётной записи завершает все её активные сессии.', 'Нельзя отключить или удалить собственную активную учётную запись.'), 'Публичные аккаунты находятся в отдельном разделе «Пользователи сайта».', 'administration/users'),
self::item('#^/roles#', 'Роли и права', 'Определяет набор разрешений для администраторов и разграничивает доступ к функциям системы.', array('Права сгруппированы по модулям и назначаются роли целиком.', 'Системная роль admin всегда имеет полный доступ.', 'Роль с назначенными пользователями нельзя удалить.'), 'После изменения роли права применяются при следующей проверке доступа пользователя.'),
self::item('#^/settings/main$#', 'Основные настройки', 'Управляет публичным доступом, параметрами сайта, почты, документов и вывода.', array('Режим разработки возвращает посетителям временную страницу 503 и закрывает динамические маршруты от индексации.', 'Сотрудник с отдельным правом продолжает видеть документы, модули и API.', 'Поля сгруппированы по назначению, а тип значения проверяется перед сохранением.', 'Секреты устанавливаемых модулей не должны храниться в общих настройках.'), 'Перед открытием сайта переключите публичный доступ и проверьте страницу в отдельном браузере без авторизации.'),
self::item('#^/settings/interface$#', 'Настройка интерфейса', 'Определяет порядок и видимость пунктов левого меню и виджетов дашборда.', array('Перетаскивание меняет порядок только для панели управления.', 'Скрытие пункта не отменяет право роли и не блокирует прямой URL.', 'Виджеты установленных модулей появляются в списке автоматически.'), 'Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню.'),
@@ -127,25 +134,25 @@
self::item('#^/events#', 'Системные события', 'Объединяет аудит действий, ошибки, SQL-журнал, 404 и статистику внешних переходов.', array('Цвет и состояние помогают отличать успешные операции, предупреждения и ошибки.', 'Переходы группируются по посетителю, странице входа и источнику; открытые IP и произвольные query-параметры не сохраняются.', 'Фильтры и CSV-экспорт работают отдельно для каждого источника.'), 'Очистка журнала необратима и не исправляет причину ошибки.'),
self::item('#^/security/ip-blocks#', 'IP-блокировки', 'Управляет ручными блокировками адресов, а автоматические ограничения выполняет RateLimiter.', array('Блокировка может быть постоянной или иметь дату окончания.', 'Причина видна администраторам и записывается в аудит.', 'Разблокировка возвращает доступ сразу.'), 'Не блокируйте адрес reverse proxy без проверки реального клиентского IP.'),
self::item('#^/database#', 'База данных', 'Показывает рабочие таблицы AVE.cms, выполняет обслуживание, применяет миграции ядра и управляет полными резервными копиями.', array('OPTIMIZE применяется только к выбранной таблице или ко всей рабочей схеме после подтверждения.', 'Применение миграций не восстанавливает данные: это отдельная операция развития структуры ядра.', 'Новые дампы используют {{prefix}}, поэтому могут восстанавливаться с другим dbpref.', 'Перед восстановлением система проверяет файл и автоматически создаёт страховочную копию.'), 'Восстановление полностью заменяет таблицы текущей установки; рабочую копию дополнительно храните вне сервера.', 'database'),
self::item('#^/kanban$#', 'Канбан', 'Организует личные рабочие карточки по колонкам и показывает доску на дашборде.', array('Карточки и порядок сохраняются без перезагрузки.', 'Колонки можно настроить под собственный процесс.', 'Каждый сотрудник работает со своей доской.'), 'Не используйте канбан как журнал системных ошибок: для них предназначены события и Todo.'),
self::item('#^/kanban$#', 'Канбан', 'Организует рабочие карточки по колонкам и показывает доску на дашборде.', array('Карточки и порядок сохраняются без перезагрузки.', 'Колонки можно настроить под собственный процесс.', 'Карточке можно назначить ответственного, срок и связанный документ.', 'Командная карточка видна коллегам, а назначенная отдельно появляется у ответственного.'), 'Колонки принадлежат владельцу доски: назначенный сотрудник видит карточку в общем списке, но не перестраивает чужую доску.'),
self::item('#^/notes$#', 'Заметки', 'Хранит личные текстовые заметки сотрудника и закрепляет важные записи на дашборде.', array('Заметки видны только их владельцу.', 'Закрепление влияет на порядок выдачи.', 'Быстрое создание доступно из шапки, если вклад модуля включён.'), 'Не храните в заметках пароли, токены и другие секреты.'),
self::item('#^/reminders$#', 'Напоминания', 'Хранит личные задачи с датой и выводит просроченные пункты в общем колокольчике.', array('Срок определяет состояние напоминания.', 'Выполнение сохраняется сразу.', 'Просроченный счётчик формируется отдельно для текущего сотрудника.'), 'Удаление напоминания необратимо; выполненный пункт можно вернуть в работу.'),
self::item('#^/notfound$#', 'Ошибки 404', 'Показывает несуществующие публичные адреса и помогает создать документ или редирект.', array('Сначала проверьте источник переходов и частоту запроса.', 'Для старого адреса существующей страницы создайте постоянный редирект.', 'Случайные сканирующие URL можно удалить из журнала без создания страницы.'), 'Редирект должен вести на действительно соответствующий документ, а не всегда на главную.'),
self::item('#^/seo-audit$#', 'SEO-аудит', 'Проверяет документы на технические и редакционные проблемы, влияющие на поисковое представление.', array('Фильтры помогают сначала разобрать критичные и массовые проблемы.', 'Оценка документа складывается из нескольких независимых проверок.', 'Исправления выполняются в редакторе документа и проявляются после повторного аудита.'), 'Автоматическая оценка является подсказкой и не заменяет проверку содержания страницы.', 'modules/seo-audit'),
self::item('#^/todo$#', 'Todo', 'Хранит личный список замечаний и следующих действий для текущего администратора.', array('Задачу можно создать из выпадающего списка в шапке на любой странице панели управления.', 'Чекбокс сразу сохраняет состояние без перезагрузки.', 'Каждый пользователь видит только собственные задачи.'), 'Удаление задачи необратимо; выполненные пункты можно вернуть в работу обычным чекбоксом.'),
self::item('#^/todo$#', 'Todo', 'Хранит рабочие задачи сотрудников со сроками и связями с документами.', array('Задачу можно создать из выпадающего списка в шапке на любой странице панели управления.', 'Ответственный видит назначенную задачу и может отметить её выполненной.', 'Режим «Команда» открывает задачу всем сотрудникам с правом просмотра Todo.', 'Связанный документ открывается прямо из строки задачи.'), 'Изменять и удалять задачу может её создатель; выполнение доступно создателю и ответственному.'),
self::item('#^/modules/search#', 'Поиск по сайту', 'Индексирует опубликованные документы и формирует обычную страницу результатов и JSON для живого поиска.', array('Области позволяют отдельно искать по товарам, новостям, статьям и другим наборам рубрик.', 'Алгоритм определяет разбор строки и вес точного совпадения, заголовка и содержимого.', 'Штатный HTML можно заменить шаблонами модуля или существующим запросом документов.'), 'После изменения рубрик или индексируемых полей выполните полную переиндексацию.', 'modules/search'),
self::item('#^/modules/commerceml#', 'Обмен с 1С', 'Принимает CommerceML по защищённому адресу и сопоставляет товары 1С с документами товарной рубрики.', array('Для каждой интеграции создайте отдельный профиль с логином, паролем и рубрикой.', 'Поля внешнего ID, артикула, цены и остатка выбираются из полей назначенной рубрики.', 'Пробный XML можно загрузить из списка профилей и проверить результат в журнале обменов.'), 'Создание неизвестных товаров выключено по умолчанию: сначала проверьте сопоставление на существующих документах.'),
self::item('#^/modules/experiments#', 'A/B-тесты', 'Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше.', array('Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии.', 'Вес вариантов задаёт распределение внутри выбранного охвата аудитории.', 'Черновик не участвует в публичном выводе; перед запуском проверьте каждый вариант.'), 'Изменение цены штатным вариантом влияет только на представление и не меняет сумму заказа.'),
self::item('#^/modules/experiments#', 'A/B-тесты', 'Распределяет посетителей между вариантами и проверяет, достаточно ли данных для вывода.', array('Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии.', 'Вес вариантов задаёт распределение внутри выбранного охвата аудитории.', 'Минимум показов защищает от слишком раннего выбора победителя.', 'После набора выборки панель показывает погрешность, достоверность и устойчивый вариант.'), 'Не останавливайте тест по одному проценту конверсии: дождитесь состояния с достоверным результатом.'),
self::item('#^/modules/popups#', 'Всплывающие окна', 'Управляет кампаниями поп-апов, условиями показа, A/B-вариантами и собранными заявками.', array('Сценарий определяет момент показа: задержку, прокрутку или попытку ухода.', 'Пути включения и исключения принимают по одному шаблону на строку; символ * заменяет произвольную часть.', 'HTML вариантов контролируется проектом, а заявки обрабатываются на отдельной вкладке.'), 'Проверьте кампанию на тестовом URL и мобильном экране до включения автоматической вставки.'),
self::item('#^/modules/reviews#', 'Отзывы', 'Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании.', array('Состояние определяет, попадёт ли отзыв в публичный список.', 'Признак подтверждённой покупки устанавливается только после проверки заказа.', 'Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном.'), 'Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить.', 'modules/reviews'),
self::item('#^/modules/reviews#', 'Отзывы', 'Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании.', array('Состояние определяет, попадёт ли отзыв в публичный список.', 'Признак подтверждённой покупки устанавливается только после проверки заказа.', 'Выберите несколько строк, чтобы опубликовать, отклонить, вернуть на модерацию или удалить их одним действием.', 'Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном.'), 'Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить.', 'modules/reviews'),
self::item('#^/modules/scheduler#', 'Планировщик', 'Собирает фоновые задачи установленных модулей в единый реестр, запускает их по расписанию и хранит журнал.', array('Защищённый URL добавляется в планировщик хостинга с интервалом в одну минуту.', 'Cron-выражение и активность каждой задачи меняются прямо в таблице.', 'Ручной запуск использует тот же обработчик и попадает в общий журнал.'), 'После смены секретного URL обновите задание на хостинге: старый адрес сразу перестанет работать.'),
self::item('#^/modules/search-analytics#', 'Поисковые запросы', 'Показывает реальные поисковые фразы посетителей, частоту запросов и случаи пустой выдачи.', array('Фильтр по области разделяет спрос по новостям, товарам, статьям и другим настроенным областям.', 'Состояние и заметка позволяют отмечать разобранные запросы и планировать новый контент.', 'Показатель «Результатов сейчас» помогает проверить, исправлена ли нулевая выдача.'), 'Очистка истории удаляет аналитические данные, но не затрагивает поисковый индекс.'),
self::item('#^/modules/system-health#', 'Мониторинг здоровья', 'Проверяет доступность базы данных, диска, фоновых задач и заданных внешних HTTPS-сервисов.', array('Текущий снимок показывает независимый результат каждой инфраструктурной проверки.', 'История помогает отличить разовый сбой от повторяющейся проблемы.', 'Для внешнего сервиса задаются ожидаемый HTTP-статус и ограниченный таймаут.'), 'Мониторинг фиксирует рабочее состояние, но не заменяет приёмочный чек-лист перед запуском сайта.'),
self::item('#^/modules/traffic-analytics#', 'Источники трафика', 'Группирует входящие переходы по UTM-меткам, источникам, кампаниям и посадочным страницам.', array('Период и фильтры одинаково применяются к сводке, таблицам и CSV-экспорту.', 'UTM-конструктор собирает ссылку в браузере и не сохраняет введённые значения.', 'Один вход посетителя на ту же страницу за день агрегируется без хранения IP.'), 'Перед очисткой выбранного периода выгрузите CSV, если данные нужны для отчётности.'),
self::item('#^/modules/antispam#', 'Антиспам', 'Настраивает профили защиты публичных форм и показывает журнал отклонённых отправок.', array('Профиль объединяет лимиты, проверку скрытого поля и анализ содержимого для конкретной формы.', 'Журнал объясняет, какое правило остановило запрос, и помогает скорректировать чувствительность.', 'Доверенные исключения применяйте только к контролируемым интеграциям.'), 'Не ослабляйте общий профиль из-за одного ложного срабатывания: создайте отдельный профиль для нужной формы.'),
self::item('#^/modules/interactions#', 'Взаимодействия', 'Хранит общий журнал оценок, реакций и голосов, который используют прикладные модули.', array('Канал определяет допустимые действия и типы объектов.', 'Агрегаты ускоряют публичный вывод счётчиков и рейтингов.', 'Пересчёт восстанавливает агрегаты из исходного журнала действий.'), 'Обычно этот раздел не вставляется в шаблон напрямую: публичный интерфейс предоставляют рейтинги, опросы и комментарии.'),
self::item('#^/modules/ratings#', 'Рейтинги', 'Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок.', array('Документы включают страницы, товары, новости и статьи, построенные на рубриках.', 'Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID.', 'Кнопки тегов вставляют доступные значения в HTML-шаблон рейтинга.'), 'Голоса и итоговые значения хранятся в модуле «Взаимодействия».'),
self::item('#^/modules/comments#', 'Комментарии', 'Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами.', array('Разрешите только те типы объектов, на которых действительно нужны обсуждения.', 'Шаблоны списка, комментария и формы имеют собственные наборы тегов.', 'Публикация, скрытие и отметка спама выполняются из общего журнала.'), 'Оценка комментариев использует отдельный канал модуля «Взаимодействия».'),
self::item('#^/modules/ratings#', 'Рейтинги', 'Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок.', array('Документы включают страницы, товары, новости и статьи, построенные на рубриках.', 'Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID.', 'Список голосов позволяет удалить ошибочную оценку или сбросить рейтинг объекта.', 'После модерации среднее значение пересчитывается автоматически.'), 'Удаление голосов необратимо и записывается в аудит; сам документ при этом не изменяется.'),
self::item('#^/modules/comments#', 'Комментарии', 'Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами.', array('Разрешите только те типы объектов, на которых действительно нужны обсуждения.', 'Шаблоны списка, комментария и формы имеют собственные наборы тегов.', 'Публикация, скрытие и отметка спама выполняются из общего журнала.', 'Выбор строк позволяет обработать до 500 комментариев одним массовым действием.'), 'Оценка комментариев использует отдельный канал модуля «Взаимодействия».'),
self::item('#^/modules/polls#', 'Опросы', 'Создаёт одиночные и множественные голосования с расписанием, доступом и управляемыми результатами.', array('Для публикации вставьте [mod_poll:ID] в документ, блок или шаблон.', 'Архив опросов доступен по настраиваемому публичному URL.', 'Шаблоны опроса, варианта, результата и архива имеют разные наборы тегов.'), 'Удаление опроса также удаляет варианты ответа и собранные голоса.'),
self::item('#^/modules/galleries#', 'Галереи', 'Собирает изображения медиатеки в упорядоченные сетки и слайдеры с собственными публичными URL.', array('Файлы остаются в медиатеке; галерея хранит только связи, подписи и порядок.', 'Тег [mod_gallery:ID] выводит коллекцию в документе, блоке или шаблоне.', 'Отдельные шаблоны управляют оболочкой галереи и разметкой одного изображения.'), 'Удаление галереи не удаляет исходные файлы и созданные для них превью.'),
self::item('#^/modules/banners#', 'Баннеры', 'Управляет рекламными местами, ротацией изображений, расписанием и внутренней статистикой переходов.', array('Стабильный код места используется в теге [mod_banner:КОД].', 'Вес влияет только на стратегию «По весу»; равномерная ротация выбирает материал с меньшим числом показов.', 'Изображения выбираются из общей медиатеки и не удаляются вместе с баннером.'), 'Нулевой лимит означает отсутствие ограничения; сброс статистики необратим.'),
+7
View File
@@ -26,6 +26,7 @@
'core.update' => 'Установка подписанного обновления ядра',
'stored_php.execute' => 'Выполнение сохранённого PHP-кода',
'stored_php.write' => 'Сохранение PHP-кода, исполняемого runtime',
'stored_html.write' => 'Сохранение HTML форм и разметки, исполняемых в публичной странице',
'theme_assets.write' => 'Изменение исполняемых CSS/JavaScript и файлов публичной темы',
'legacy_migration.write' => 'Изменение реквизитов исходной системы',
'legacy_migration.execute' => 'Очистка стартовых данных и миграция старой AVE.cms',
@@ -86,6 +87,12 @@
'POST /modules/legacy-migration/runs/{id}/rollback' => array('legacy_migration.execute', 'run_legacy_migration'),
'POST /modules/legacy-migration/runs/{id}/files' => array('legacy_migration.execute', 'run_legacy_migration'),
'POST /database/backup/restore' => array('database.restore', 'manage_database'),
'POST /modules/popups/{id}' => array('stored_html.write', 'manage_popup_code'),
'POST /modules/popups/{id}/delete' => array('stored_html.write', 'manage_popup_code'),
'POST /modules/popups/settings' => array('stored_html.write', 'manage_popup_code'),
'POST /modules/experiments/{id}' => array('stored_html.write', 'manage_experiment_code'),
'POST /modules/experiments/{id}/delete' => array('stored_html.write', 'manage_experiment_code'),
'POST /modules/experiments/settings' => array('stored_html.write', 'manage_experiment_code'),
);
public static function validate(array $route)
+196
View File
@@ -0,0 +1,196 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/ViewOverrideEditor.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Support;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Twig;
use App\Adminx\Themes\Model;
use App\Frontend\ThemeAssets;
use App\Helpers\Json;
/** Safe editor for a fixed set of public Twig view overrides. */
class ViewOverrideEditor
{
protected $namespace;
protected $fallbackRoot;
protected $fallbackPrefix;
protected $definitions;
public function __construct($namespace, $fallbackRoot, $fallbackPrefix, array $definitions)
{
$namespace = trim((string) $namespace);
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $namespace)) {
throw new \InvalidArgumentException('Некорректное пространство публичных шаблонов');
}
$this->namespace = $namespace;
$this->fallbackRoot = rtrim(str_replace('\\', '/', (string) $fallbackRoot), '/');
$this->fallbackPrefix = trim(str_replace('\\', '/', (string) $fallbackPrefix), '/');
$this->definitions = $this->normalizeDefinitions($definitions);
}
public function all()
{
$out = array();
foreach ($this->definitions as $code => $definition) {
$out[] = $this->describe($code, $definition);
}
return $out;
}
public function one($code)
{
$code = trim((string) $code);
if ($code === '' || !isset($this->definitions[$code])) {
$code = (string) key($this->definitions);
}
return $this->describe($code, $this->definitions[$code], true);
}
public function save($code, $content, $authorId, $action = 'public-view')
{
$this->assertCode($code);
$item = $this->one($code);
$syntax = $this->lint($content, $code);
if (empty($syntax['ok'])) {
throw new \InvalidArgumentException($syntax['message']);
}
Model::saveFile($item['theme'], $item['theme_path'], (string) $content, (int) $authorId, $action);
$this->ensureNamespace($item['theme'], (int) $authorId);
return $this->one($item['code']);
}
public function deleteOverride($code, $authorId)
{
$this->assertCode($code);
$item = $this->one($code);
if (empty($item['has_fallback'])) {
throw new \RuntimeException('Для этого шаблона нет резервной версии');
}
if (empty($item['has_theme_file'])) {
throw new \RuntimeException('Переопределение темы не найдено');
}
Model::deletePath($item['theme'], $item['theme_path'], (int) $authorId);
return $this->one($item['code']);
}
public function lint($content, $code = 'template')
{
$content = (string) $content;
if (trim($content) === '') {
return array('ok' => false, 'message' => 'Twig-шаблон не может быть пустым');
}
try {
Twig::twig()->createTemplate($content, 'public_view_' . preg_replace('/[^a-z0-9_]/i', '_', (string) $code));
} catch (\Throwable $e) {
return array('ok' => false, 'message' => 'Ошибка Twig: ' . $e->getMessage());
}
return array('ok' => true, 'message' => 'Twig-синтаксис корректен');
}
protected function describe($code, array $definition, $includeContent = false)
{
$theme = ThemeAssets::currentTheme();
$themePath = 'views/' . $this->namespace . '/' . $definition['file'];
$themeFile = BASEPATH . '/templates/' . $theme . '/' . $themePath;
$fallbackFile = $this->fallbackRoot !== '' ? $this->fallbackRoot . '/' . $definition['file'] : '';
$manifest = ThemeAssets::manifest($theme);
$namespaceEnabled = in_array($this->namespace, isset($manifest['view_overrides']) ? $manifest['view_overrides'] : array(), true);
$hasThemeFile = is_file($themeFile) && is_readable($themeFile);
$hasFallback = $fallbackFile !== '' && is_file($fallbackFile) && is_readable($fallbackFile);
$activeOverride = $namespaceEnabled && $hasThemeFile;
$source = $activeOverride ? 'theme' : ($hasThemeFile ? 'theme_disabled' : ($hasFallback ? 'fallback' : 'missing'));
$item = array_merge($definition, array(
'code' => $code,
'namespace' => $this->namespace,
'theme' => $theme,
'theme_path' => $themePath,
'fallback_path' => $this->fallbackPrefix !== '' ? $this->fallbackPrefix . '/' . $definition['file'] : '',
'has_theme_file' => $hasThemeFile,
'has_fallback' => $hasFallback,
'can_delete_override' => $hasThemeFile && $hasFallback,
'namespace_enabled' => $namespaceEnabled,
'active_override' => $activeOverride,
'source' => $source,
'source_label' => $activeOverride ? 'Активная тема' : ($hasThemeFile ? 'Файл темы отключён' : ($hasFallback ? 'Резерв компонента' : 'Файл отсутствует')),
'modified_label' => $hasThemeFile ? date('d.m.Y H:i', (int) filemtime($themeFile)) : ($hasFallback ? date('d.m.Y H:i', (int) filemtime($fallbackFile)) : ''),
));
if ($includeContent) {
$sourceFile = $hasThemeFile ? $themeFile : $fallbackFile;
$item['content'] = $sourceFile !== '' && is_file($sourceFile) ? (string) file_get_contents($sourceFile) : '';
$item['fallback_content'] = $hasFallback ? (string) file_get_contents($fallbackFile) : '';
}
return $item;
}
protected function ensureNamespace($theme, $authorId)
{
$manifest = ThemeAssets::manifest($theme);
$overrides = isset($manifest['view_overrides']) && is_array($manifest['view_overrides'])
? $manifest['view_overrides']
: array();
if (in_array($this->namespace, $overrides, true)) { return; }
$overrides[] = $this->namespace;
$manifest['view_overrides'] = array_values(array_unique($overrides));
Model::saveFile(
$theme,
ThemeAssets::MANIFEST,
Json::encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n",
(int) $authorId,
'public-view-namespace'
);
}
protected function assertCode($code)
{
if (!isset($this->definitions[trim((string) $code)])) {
throw new \InvalidArgumentException('Публичный шаблон не найден');
}
}
protected function normalizeDefinitions(array $definitions)
{
$out = array();
foreach ($definitions as $code => $definition) {
$code = trim((string) $code);
$file = isset($definition['file']) ? ThemeAssets::normalizePath($definition['file']) : '';
if (!preg_match('/^[a-z][a-z0-9_-]{0,63}$/', $code) || $file === '' || substr($file, -5) !== '.twig') {
throw new \InvalidArgumentException('Некорректное описание публичного шаблона');
}
$definition['file'] = $file;
$definition['title'] = isset($definition['title']) ? (string) $definition['title'] : $code;
$definition['description'] = isset($definition['description']) ? (string) $definition['description'] : '';
$definition['icon'] = isset($definition['icon']) ? (string) $definition['icon'] : 'ti ti-template';
$definition['group'] = isset($definition['group']) ? (string) $definition['group'] : '';
$definition['variables'] = isset($definition['variables']) && is_array($definition['variables']) ? $definition['variables'] : array();
$out[$code] = $definition;
}
if (!$out) { throw new \InvalidArgumentException('Реестр публичных шаблонов пуст'); }
return $out;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/WorkItemContext.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Support;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\SystemTables;
use App\Common\Permission;
use App\Content\ContentTables;
use App\Content\Documents\DocumentPickerRepository;
use DB;
/** Shared assignee and document context for panel work items. */
class WorkItemContext
{
public static function users()
{
$rows = DB::query(
'SELECT id,name,email FROM ' . SystemTables::table('users')
. ' WHERE is_active=1 ORDER BY name,email,id'
)->getAll();
$result = array();
foreach ($rows ?: array() as $row) {
$result[] = array(
'id' => (int) $row['id'],
'name' => trim((string) $row['name']) !== '' ? (string) $row['name'] : (string) $row['email'],
'email' => (string) $row['email'],
);
}
return $result;
}
public static function assignee($userId)
{
$userId = max(0, (int) $userId);
if ($userId < 1) { return 0; }
return (int) DB::query(
'SELECT id FROM ' . SystemTables::table('users') . ' WHERE id=%i AND is_active=1 LIMIT 1',
$userId
)->getValue();
}
public static function documents($query, $limit = 20)
{
if (!Permission::check('view_documents')) { return array(); }
return (new DocumentPickerRepository())->search($query, array(), $limit);
}
public static function target($type, $id)
{
if (!Permission::check('view_documents')) { return array('', 0); }
$type = strtolower(trim((string) $type));
$id = max(0, (int) $id);
if ($type !== 'document' || $id < 1) { return array('', 0); }
$exists = (int) DB::query(
'SELECT Id FROM ' . ContentTables::table('documents') . ' WHERE Id=%i AND document_deleted!=%s LIMIT 1',
$id,
'1'
)->getValue();
return $exists > 0 ? array('document', $exists) : array('', 0);
}
public static function decorateTargets(array &$items)
{
if (!Permission::check('view_documents')) { return; }
$ids = array();
foreach ($items as $item) {
if (isset($item['target_type'], $item['target_id']) && $item['target_type'] === 'document' && (int) $item['target_id'] > 0) {
$ids[(int) $item['target_id']] = (int) $item['target_id'];
}
}
$documents = array();
if ($ids) {
$rows = DB::query(
'SELECT Id,document_title,document_alias FROM ' . ContentTables::table('documents')
. ' WHERE Id IN (' . implode(',', $ids) . ') AND document_deleted!=%s',
'1'
)->getAll();
foreach ($rows ?: array() as $row) { $documents[(int) $row['Id']] = $row; }
}
foreach ($items as &$item) {
$item['target_title'] = '';
$item['target_alias'] = '';
$item['target_url'] = '';
$id = isset($item['target_id']) ? (int) $item['target_id'] : 0;
if (!isset($documents[$id])) { continue; }
$item['target_title'] = htmlspecialchars_decode((string) $documents[$id]['document_title'], ENT_QUOTES);
$item['target_alias'] = (string) $documents[$id]['document_alias'];
$item['target_url'] = rtrim(ADMINX_BASE, '/') . '/documents/' . $id . '/edit';
}
unset($item);
}
public static function dueAt($value)
{
$value = trim((string) $value);
if ($value === '') { return 0; }
$timestamp = strtotime($value);
return $timestamp === false ? 0 : max(0, (int) $timestamp);
}
}
+40 -8
View File
@@ -575,6 +575,8 @@
// cancelLabel, onConfirm });
// ------------------------------------------------------------------ //
Adminx.Confirm = {
active: null,
ICONS: {
info: ['info', 'ti-info-circle'],
success: ['success', 'ti-circle-check'],
@@ -584,6 +586,11 @@
open: function (cfg) {
cfg = cfg || {};
if (this.active) {
this.active.focus();
return false;
}
var kind = this.ICONS[cfg.kind] ? cfg.kind : 'warning';
var ic = this.ICONS[kind];
@@ -615,21 +622,46 @@
requestAnimationFrame(function () { overlay.classList.add('show'); });
var self = this;
var close = function () {
var previousFocus = document.activeElement;
var confirmButton = modal.querySelector('[data-ok]');
var settled = false;
var finish = function (confirmed) {
if (settled) { return; }
settled = true;
document.removeEventListener('keydown', onKey, true);
self.active = null;
overlay.classList.remove('show');
setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180);
setTimeout(function () {
overlay.remove();
if (!confirmed && previousFocus && document.contains(previousFocus)) {
previousFocus.focus();
}
}, 180);
if (confirmed && typeof cfg.onConfirm === 'function') {
cfg.onConfirm();
}
};
var onKey = function (e) {
if (e.key !== 'Escape' && e.key !== 'Enter') { return; }
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); }
finish(e.key === 'Enter');
};
var onKey = function (e) { if (e.key === 'Escape') { close(); } };
overlay.addEventListener('click', function (e) {
if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); return; }
if (e.target === overlay || e.target.closest('[data-cancel]')) { finish(false); return; }
if (e.target.closest('[data-ok]')) {
close();
if (typeof cfg.onConfirm === 'function') { cfg.onConfirm(); }
finish(true);
}
});
document.addEventListener('keydown', onKey);
modal.querySelector('[data-ok]').focus();
document.addEventListener('keydown', onKey, true);
this.active = {
focus: function () { confirmButton.focus(); },
close: function () { finish(false); }
};
confirmButton.focus();
return true;
}
};
+12
View File
@@ -172,6 +172,18 @@
$authUser && Auth::ensureBrowserToken();
$canAdmin = $authUser !== null && Permission::checkAcp('admin_panel');
$forcePasswordPath = in_array($requestPath, array('/account/password', '/logout', '/locale'), true);
if ($canAdmin && !empty($authUser['must_change_password']) && !$forcePasswordPath) {
if (Request::isAjax()) {
Response::json(array(
'success' => false,
'message' => 'Сначала смените временный пароль.',
'redirect' => ADMINX_BASE . '/account/password',
), 428);
}
Request::redirect(ADMINX_BASE . '/account/password');
}
//-- Пункты меню для layout: фильтр по правам + активный пункт + абсолютный href.
$navFlat = [];
+103 -8
View File
@@ -57,6 +57,16 @@
$login = Request::postStr('login');
$password = (string) Request::post('password', '');
$remember = Request::postBool('remember', false);
$identityKey = 'admin-login-user:' . hash('sha256', mb_strtolower(trim($login), 'UTF-8'));
$ipKey = 'admin-login-ip:' . Request::ip();
if (RateLimiter::tooManyAttempts($identityKey, 5) || RateLimiter::tooManyAttempts($ipKey, 20)) {
$this->auditLoginFailure($login, 'rate_limit');
$wait = max(RateLimiter::availableIn($identityKey), RateLimiter::availableIn($ipKey));
return $this->render('@auth/login.twig', array(
'error' => 'Слишком много попыток входа. Повторите через ' . max(1, $wait) . ' сек.',
'login_prev' => $login,
));
}
//-- Современный Auth API (SystemTables users: email/password_hash/role).
//-- Вход по email или логину; запоминание — по флажку.
@@ -68,19 +78,20 @@
$result = Auth::attempt($login, $password, $options);
if (is_array($result)) {
Session::regenerateCsrf();
$this->redirect($this->base() . '/');
return null;
return $this->loginCompleted($result, $identityKey, $ipKey);
}
//-- Legacy-пароль (перенесённые пользователи, md5(md5(pass+salt))):
//-- проверяем и молча перехешируем в bcrypt при первом входе.
if ($this->legacyLogin($login, $password, $options)) {
Session::regenerateCsrf();
$this->redirect($this->base() . '/');
return null;
$legacyUser = $this->legacyLogin($login, $password, $options);
if (is_array($legacyUser)) {
return $this->loginCompleted($legacyUser, $identityKey, $ipKey);
}
RateLimiter::hit($identityKey, 600);
RateLimiter::hit($ipKey, 600);
$this->auditLoginFailure($login, isset($result) ? (string) $result : 'failed');
$messages = [
1 => AdminLocale::text('auth_err_required', 'Введите логин или email и пароль.'),
2 => AdminLocale::text('auth_err_invalid', 'Неверный логин или пароль.'),
@@ -96,6 +107,61 @@
]);
}
/** GET /account/password — mandatory temporary-password replacement. */
public function passwordForm(array $params = array())
{
$user = Auth::user();
if (!$user) { $this->redirect($this->base() . '/login'); return null; }
return $this->render('@auth/password.twig', array(
'error' => '',
'user' => $user,
));
}
/** POST /account/password — finish a forced password change. */
public function changePassword(array $params = array())
{
$user = Auth::user();
if (!$user) { $this->redirect($this->base() . '/login'); return null; }
try {
$this->verifyCsrf();
} catch (\RuntimeException $e) {
return $this->render('@auth/password.twig', array(
'error' => 'Сессия устарела, обновите страницу и попробуйте снова.', 'user' => $user,
));
}
$current = (string) Request::post('current_password', '');
$password = (string) Request::post('password', '');
$confirm = (string) Request::post('password_confirm', '');
$error = '';
if (!password_verify($current, (string) $user['password_hash'])) {
$error = 'Текущий пароль указан неверно.';
} elseif (mb_strlen($password, 'UTF-8') < 8) {
$error = 'Новый пароль должен содержать не меньше 8 символов.';
} elseif (!hash_equals($password, $confirm)) {
$error = 'Новые пароли не совпадают.';
} elseif (password_verify($password, (string) $user['password_hash'])) {
$error = 'Новый пароль должен отличаться от временного.';
}
if ($error !== '') {
return $this->render('@auth/password.twig', array('error' => $error, 'user' => $user));
}
\App\Adminx\Users\Model::completePasswordChange((int) $user['id'], $password);
AuditLog::record('auth.password_changed', array(
'actor_id' => (int) $user['id'],
'actor_name' => isset($user['name']) ? (string) $user['name'] : '',
'target_type' => 'user',
'target_id' => (int) $user['id'],
));
Session::regenerateCsrf();
$this->redirect($this->base() . '/');
return null;
}
/** POST /locale — смена только языка интерфейса Adminx. */
public function language(array $params = array())
{
@@ -181,7 +247,36 @@
$arr['password_hash'] = $hash;
Auth::login($arr, $options);
return true;
return $arr;
}
protected function loginCompleted(array $user, $identityKey, $ipKey)
{
RateLimiter::clear($identityKey);
RateLimiter::clear($ipKey);
\App\Adminx\Users\Model::recordSuccessfulLogin((int) $user['id']);
AuditLog::record('auth.login_succeeded', array(
'actor_id' => (int) $user['id'],
'actor_name' => isset($user['name']) ? (string) $user['name'] : '',
'target_type' => 'user',
'target_id' => (int) $user['id'],
));
Session::regenerateCsrf();
$this->redirect($this->base() . (!empty($user['must_change_password']) ? '/account/password' : '/'));
return null;
}
protected function auditLoginFailure($identifier, $reason)
{
$user = \App\Adminx\Users\Model::findByIdentifier((string) $identifier);
AuditLog::record('auth.login_failed', array(
'target_type' => 'user',
'target_id' => $user ? (int) $user->id : null,
'meta' => array(
'identifier' => mb_substr(trim((string) $identifier), 0, 190, 'UTF-8'),
'reason' => (string) $reason,
),
));
}
/** POST /logout — выход (ajax + CSRF). */
@@ -13,4 +13,13 @@
<phrase data="auto_94d6ba09fac4d9d6">The session is out of date, please refresh the page and try again.</phrase>
<phrase data="auto_941abeff84af0919">Too many tries. Repeat after</phrase>
<phrase data="auto_5a6373e98b6ebb55">The account has been disabled.</phrase>
<phrase data="auth_temp_password_title">Temporary password change</phrase>
<phrase data="auth_temp_password_heading">Change your temporary password</phrase>
<phrase data="auth_temp_password_intro">The control panel will open after the password is changed.</phrase>
<phrase data="auth_temp_password_for">For</phrase>
<phrase data="auth_temp_password_current">Temporary password</phrase>
<phrase data="auth_temp_password_new">New password</phrase>
<phrase data="auth_temp_password_hint">At least 6 characters; the password must differ from the temporary one.</phrase>
<phrase data="auth_temp_password_confirm">Repeat the new password</phrase>
<phrase data="auth_temp_password_save">Save new password</phrase>
</language>
@@ -13,4 +13,9 @@
<phrase data="runtime_4ffa62424067c80f">Too many tries. Repeat after</phrase>
<phrase data="runtime_7cc36c1848c1e707">The account has been disabled.</phrase>
<phrase data="runtime_46a81a92e59e6d10">sec.</phrase>
<phrase data="auth_login_rate_limited">Too many login attempts. Try again in</phrase>
<phrase data="auth_current_password_invalid">The current password is incorrect.</phrase>
<phrase data="auth_new_password_short">The new password must contain at least 6 characters.</phrase>
<phrase data="auth_new_password_mismatch">The new passwords do not match.</phrase>
<phrase data="auth_new_password_same">The new password must differ from the temporary one.</phrase>
</language>
@@ -13,4 +13,13 @@
<phrase data="auto_94d6ba09fac4d9d6">Сессия устарела, обновите страницу и попробуйте снова.</phrase>
<phrase data="auto_941abeff84af0919">Слишком много попыток. Повторите через</phrase>
<phrase data="auto_5a6373e98b6ebb55">Учётная запись отключена.</phrase>
<phrase data="auth_temp_password_title">Смена временного пароля</phrase>
<phrase data="auth_temp_password_heading">Смените временный пароль</phrase>
<phrase data="auth_temp_password_intro">После смены откроется панель управления.</phrase>
<phrase data="auth_temp_password_for">Для</phrase>
<phrase data="auth_temp_password_current">Временный пароль</phrase>
<phrase data="auth_temp_password_new">Новый пароль</phrase>
<phrase data="auth_temp_password_hint">Минимум 6 символов, пароль должен отличаться от временного.</phrase>
<phrase data="auth_temp_password_confirm">Повторите новый пароль</phrase>
<phrase data="auth_temp_password_save">Сохранить новый пароль</phrase>
</language>
@@ -13,4 +13,9 @@
<phrase data="runtime_4ffa62424067c80f">Слишком много попыток. Повторите через</phrase>
<phrase data="runtime_7cc36c1848c1e707">Учётная запись отключена.</phrase>
<phrase data="runtime_46a81a92e59e6d10">сек.</phrase>
<phrase data="auth_login_rate_limited">Слишком много попыток входа. Повторите через</phrase>
<phrase data="auth_current_password_invalid">Текущий пароль указан неверно.</phrase>
<phrase data="auth_new_password_short">Новый пароль должен содержать не меньше 6 символов.</phrase>
<phrase data="auth_new_password_mismatch">Новые пароли не совпадают.</phrase>
<phrase data="auth_new_password_same">Новый пароль должен отличаться от временного.</phrase>
</language>
+3 -1
View File
@@ -23,7 +23,7 @@
return [
'code' => 'auth',
'name' => 'Доступ',
'version' => '0.1.0',
'version' => '0.2.1',
'public_routes' => [
'/login',
@@ -34,6 +34,8 @@
array('GET', '/login', array(\App\Adminx\Auth\Controller::class, 'form')),
array('POST', '/login', array(\App\Adminx\Auth\Controller::class, 'login')),
array('POST', '/locale', array(\App\Adminx\Auth\Controller::class, 'language')),
array('GET', '/account/password', array(\App\Adminx\Auth\Controller::class, 'passwordForm'), array('permission' => 'admin_panel')),
array('POST', '/account/password', array(\App\Adminx\Auth\Controller::class, 'changePassword'), array('permission' => 'admin_panel')),
array('POST', '/reauth', array(\App\Adminx\Auth\Controller::class, 'reauth'), array('permission' => 'admin_panel')),
array('POST', '/logout', array(\App\Adminx\Auth\Controller::class, 'logout')),
),
+22
View File
@@ -0,0 +1,22 @@
{% extends '@adminx/auth.twig' %}
{% block title %}{{ lang.auth_temp_password_title|default('Смена временного пароля') }}{% endblock %}
{% block content %}
<div class="auth-wrap">
<div class="card auth-card">
<div class="auth-head">
<h1 class="auth-logo-h"><img class="auth-logo" src="{{ asset_ver(ADMINX_BASE ~ '/assets/img/logo.svg') }}" alt="AVE.cms" width="132" height="47"></h1>
<h1>{{ lang.auth_temp_password_heading|default('Смените временный пароль') }}</h1>
<p class="text-secondary">{{ lang.auth_temp_password_for|default('Для') }} {{ user.name|default(user.email) }}. {{ lang.auth_temp_password_intro|default('После смены откроется панель управления.') }}</p>
</div>
{% if error %}<div class="alert alert-danger" role="alert"><i class="ti ti-alert-triangle"></i><span>{{ error }}</span></div>{% endif %}
<form method="post" action="{{ ADMINX_BASE }}/account/password" class="auth-form">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field"><label for="currentPassword">{{ lang.auth_temp_password_current|default('Временный пароль') }}</label><input class="input" type="password" id="currentPassword" name="current_password" autocomplete="current-password" autofocus required></div>
<div class="field"><label for="newPassword">{{ lang.auth_temp_password_new|default('Новый пароль') }}</label><input class="input" type="password" id="newPassword" name="password" minlength="8" autocomplete="new-password" required><span class="field-hint">{{ lang.auth_temp_password_hint|default('Минимум 8 символов, пароль должен отличаться от временного.') }}</span></div>
<div class="field"><label for="passwordConfirm">{{ lang.auth_temp_password_confirm|default('Повторите новый пароль') }}</label><input class="input" type="password" id="passwordConfirm" name="password_confirm" minlength="8" autocomplete="new-password" required></div>
<button class="btn btn-primary btn-block" type="submit"><i class="ti ti-key"></i>{{ lang.auth_temp_password_save|default('Сохранить новый пароль') }}</button>
</form>
</div>
<div class="auth-foot text-muted text-xs">AVE.cms · {{ lang.app_admin_panel|default('Панель управления') }}</div>
</div>
{% endblock %}
+5
View File
@@ -133,6 +133,10 @@
$row = self::store()->formatRow($row, $withSnapshot);
$snapshot = $row['snapshot'];
$text = $withSnapshot && is_array($snapshot) && isset($snapshot['sysblock_text']) ? (string) $snapshot['sysblock_text'] : '';
$current = $withSnapshot ? Model::raw((int) $row['block_id']) : array();
$comparison = $withSnapshot && is_array($snapshot)
? JsonRevisionStore::compareSnapshots($current ? Model::snapshot($current) : array(), $snapshot)
: array();
return array(
'id' => (int) $row['id'],
@@ -152,6 +156,7 @@
'text_size_label' => $withSnapshot ? JsonRevisionStore::formatBytes(strlen($text)) : '',
'snapshot' => $snapshot,
'code' => $text,
'comparison' => $comparison,
);
}
+4 -2
View File
@@ -720,6 +720,8 @@
var fields = document.querySelector('[data-revision-fields]');
var restore = document.querySelector('[data-revision-restore]');
var remove = document.querySelector('[data-revision-delete]');
var comparison = item.comparison || {};
var changed = comparison.summary ? parseInt(comparison.summary.total_changed || 0, 10) : 0;
var clear = document.querySelector('[data-revisions-clear]');
if (title) { title.textContent = 'Ревизии: ' + (row.dataset.name || ('#' + row.dataset.id)); }
@@ -831,7 +833,7 @@
if (title) { title.textContent = '#' + item.id + ' · ' + (item.action_label || item.action || 'Ревизия'); }
if (meta) {
meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : '');
meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : '') + ' · ' + (changed ? ('изменений: ' + changed) : 'совпадает с текущим');
}
if (fields) {
fields.innerHTML =
@@ -845,7 +847,7 @@
(String(snapshot.sysblock_visual) === '1' ? ', visual' : '') +
'</em></span>';
}
if (restore) { restore.disabled = !this.currentRevisionId; }
if (restore) { restore.disabled = !this.currentRevisionId || !changed; }
if (remove) {
remove.disabled = !this.currentRevisionId;
if (this.currentRevisionId) { remove.setAttribute('data-revision-delete', String(this.currentRevisionId)); }
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'blocks',
'name' => 'Блоки',
'version' => '0.1.3',
'version' => '0.1.4',
'permissions' => array(
'key' => 'blocks',
+135 -6
View File
@@ -32,6 +32,7 @@
use App\Adminx\Support\CodeEditor;
use App\Adminx\Support\SavedViews;
use App\Content\PaymentPrograms;
use App\Content\Documents\DocumentMediaDraft;
use App\Content\ProductShipping;
use App\Frontend\ThemeAssets;
use App\Adminx\Packages\Products\AttributeNormalization;
@@ -48,7 +49,9 @@
use App\Adminx\Packages\Products\ProductRelations;
use App\Adminx\Packages\Products\ProductCollections;
use App\Adminx\Packages\Products\ProductDemands;
use App\Adminx\Packages\Products\ProductDuplicator;
use App\Adminx\Packages\Products\ProductReadiness;
use App\Adminx\Packages\Products\PublicViewTemplates;
use App\Adminx\Packages\Products\ShippingPackageTemplates;
use App\Adminx\Packages\Products\VariantGroups;
use App\Adminx\Packages\Commerce\Promotions;
@@ -96,6 +99,8 @@
'filters' => $filters,
'saved_views' => SavedViews::all('catalog_products', Auth::id(), $this->savedViewFields('products')),
'can_manage' => Permission::check('manage_products'),
'can_copy' => Permission::check('manage_products') && Permission::check('manage_documents'),
'can_toggle' => Permission::check('manage_products') && Permission::check('manage_documents'),
));
}
@@ -118,6 +123,7 @@
'state' => $state,
'page' => Request::getInt('page', 1),
'per_page' => Request::getInt('per_page', 25),
'include_quality' => true,
);
return $this->render('@products/product-quality.twig', array(
'result' => Model::products($filters),
@@ -475,6 +481,55 @@
));
}
public function publicTemplates(array $params = array())
{
if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); }
$this->publicTemplateAssets();
return $this->render('@themes/view-overrides.twig', array(
'page_title' => 'Публичные шаблоны товаров',
'page_description' => 'Каталог, карточки, страница товара, сравнение и обращения в активной теме.',
'back_url' => '/catalog/products',
'back_label' => 'Товары',
'route_base' => '/catalog/public-templates',
'list_title' => 'Публичный вывод',
'tabs_template' => '@products/_tabs.twig',
'product_tab' => 'public_templates',
'templates' => PublicViewTemplates::all(),
'selected' => PublicViewTemplates::one(Request::getStr('template', '')),
'can_manage' => Permission::check('manage_products') && Permission::check('manage_themes'),
'can_view_themes' => Permission::check('view_themes'),
));
}
public function lintPublicTemplate(array $params = array())
{
if (($error = $this->publicTemplateGuard(false)) !== null) { return $error; }
$result = PublicViewTemplates::lint(Request::postStr('content', ''));
return !empty($result['ok'])
? $this->success($result['message'], array('data' => $result))
: $this->error($result['message'], array('content' => $result['message']), 422);
}
public function savePublicTemplate(array $params = array())
{
if (($error = $this->publicTemplateGuard()) !== null) { return $error; }
$code = isset($params['code']) ? (string) $params['code'] : '';
try { $item = PublicViewTemplates::save($code, Request::postStr('content', ''), Auth::id()); }
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
$this->audit('catalog.public_template_saved', null, array('theme' => $item['theme'], 'path' => $item['theme_path']));
return $this->success('Шаблон активной темы сохранён', array('data' => $item));
}
public function deletePublicTemplate(array $params = array())
{
if (($error = $this->publicTemplateGuard()) !== null) { return $error; }
$code = isset($params['code']) ? (string) $params['code'] : '';
try { $item = PublicViewTemplates::deleteOverride($code, Auth::id()); }
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
$this->audit('catalog.public_template_override_deleted', null, array('theme' => $item['theme'], 'path' => $item['theme_path']));
return $this->success('Переопределение удалено, используется шаблон модуля', array('data' => $item));
}
public function cardTemplate(array $params = array())
{
if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); }
@@ -536,7 +591,7 @@
if (!$syntax['ok']) { return $this->error($syntax['message'], array('draft_markup' => $syntax['message']), 422); }
try {
$this->bootPublicCatalogPreview();
$catalog = \App\Modules\Products\Repository::fromDocumentId(Request::postInt('preview_catalog_id', 0));
$catalog = \App\Modules\Products\Repository::forFilterPreviewFromDocumentId(Request::postInt('preview_catalog_id', 0));
if (!$catalog) { throw new \RuntimeException('Выберите раздел каталога с настроенными фильтрами'); }
$result = \App\Modules\Products\FilterTemplateRenderer::preview($catalog, $markup, $css);
$html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
@@ -729,13 +784,14 @@
if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); }
$this->attributeAssets();
$view = Request::getStr('view', 'overview');
if (!in_array($view, array('overview', 'normalization', 'attributes', 'sets', 'migration', 'sections'), true)) { $view = 'overview'; }
if (!in_array($view, array('overview', 'normalization', 'attributes', 'sets', 'filters', 'migration', 'sections'), true)) { $view = 'overview'; }
return $this->render('@products/attributes.twig', array(
'view' => $view, 'stats' => Attributes::stats(), 'value_types' => Attributes::valueTypes(),
'attributes' => Attributes::all(Request::getStr('q', ''), Request::getStr('status', '')),
'product_attributes' => $view === 'migration' ? Attributes::all('', 'active', 'product') : array(),
'sets' => Attributes::sets(), 'legacy_fields' => $view === 'migration' ? Attributes::legacyFields() : array(),
'sections' => $view === 'sections' ? Attributes::sections() : array(),
'section_filters' => $view === 'filters' ? Attributes::sectionFilterEditor(Request::getInt('section_id', 0)) : array(),
'normalization' => $view === 'normalization' ? AttributeNormalization::audit() : array(),
'overview' => $view === 'overview' ? Attributes::overview() : array(),
'filters' => array('q'=>Request::getStr('q',''),'status'=>Request::getStr('status','')),
@@ -866,6 +922,19 @@
if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::saveSection((int)$params['id'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Режим раздела сохранён',array('data'=>$data));
}
public function saveAttributeSectionFilters(array $params = array())
{
if (($error = $this->productGuard()) !== null) { return $error; }
$id = isset($params['id']) ? (int) $params['id'] : 0;
try { $data = Attributes::saveSectionFilters($id, Request::postAll(), Auth::id()); }
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
$this->audit('catalog.attribute_section_filters_saved', $id, $data);
return $this->success('Фильтры раздела сохранены', array(
'redirect' => $this->base() . '/catalog/attributes?view=filters&section_id=' . $id,
'data' => $data,
));
}
public function rebuildAttributeShadow(array $params = array())
{
if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::rebuildShadowIndex((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_shadow_rebuilt',(int)$params['id'],$data);return $this->success('Теневой индекс раздела перестроен',array('data'=>$data));
@@ -1103,8 +1172,8 @@
$sourceId = (int) $params['product'];
$newId = 0;
try {
$newId = DocumentsModel::copy($sourceId, Auth::id());
Model::reindexDocument($newId);
$copy = ProductDuplicator::copy($sourceId, Auth::id());
$newId = (int) $copy['id'];
VariantGroups::add((int) $params['id'], $newId);
} catch (\Throwable $e) {
if ($newId > 0) { try { DocumentsModel::delete($newId); DocumentsModel::purge($newId); } catch (\Throwable $cleanupError) {} }
@@ -1115,6 +1184,45 @@
return $this->success('Вариант создан копированием', array('redirect' => $this->base() . '/catalog/products/' . $newId . '/edit'));
}
public function copyProduct(array $params = array())
{
if (($error = $this->guardPermission('manage_products', 'manage_documents')) !== null) { return $error; }
$sourceId = isset($params['id']) ? (int) $params['id'] : 0;
try {
$copy = ProductDuplicator::copy($sourceId, Auth::id());
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
$this->audit('catalog.product_copied', (int) $copy['id'], array(
'source_id' => $sourceId,
'attributes' => (int) $copy['attributes'],
'shipping' => !empty($copy['shipping']),
));
return $this->success('Копия товара создана', array(
'id' => (int) $copy['id'],
'redirect' => $this->base() . '/catalog/products/' . (int) $copy['id'] . '/edit',
));
}
public function toggleProduct(array $params = array())
{
if (($error = $this->guardPermission('manage_products', 'manage_documents')) !== null) { return $error; }
$productId = isset($params['id']) ? (int) $params['id'] : 0;
if (!Model::product($productId)) { return $this->error('Товар не найден', array(), 404); }
try {
$status = DocumentsModel::toggleStatus($productId);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
if ($status === false) { return $this->error('Товар не найден', array(), 404); }
$this->audit('catalog.product_status_changed', $productId, array('status' => (int) $status));
return $this->success($status ? 'Товар опубликован' : 'Товар снят с публикации', array(
'data' => array('status' => (int) $status),
));
}
public function deleteVariantGroup(array $params = array())
{
if (($error = $this->productGuard()) !== null) { return $error; }
@@ -1181,14 +1289,16 @@
$variantGroup = VariantGroups::membership($id);
$shippingProfile = ProductShipping::profile($id);
$nativeAttributes = Attributes::productEditor($id);
$mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), $id);
return $this->render('@documents/edit.twig', array(
'document' => $document,
'field_groups' => DocumentsModel::fieldsForDocument($id),
'field_groups' => DocumentsModel::fieldsForDocument($id, $mediaDraftToken),
'media_draft_token' => $mediaDraftToken,
'rubrics' => DocumentsModel::rubrics(),
'templates' => DocumentsModel::rubricTemplates((int) $document['rubric_id']),
'navigation_items' => DocumentsModel::navigationItems(),
'authors' => DocumentsModel::authors(),
'is_new' => false, 'can_manage' => true,
'is_new' => false, 'can_manage' => true, 'quick_edit' => Request::getBool('quick_edit', false),
'actor_id' => Auth::id(),
'catalog_mode' => true, 'product' => $product, 'variant_group' => $variantGroup,
'shipping_profile' => $shippingProfile,
@@ -1199,6 +1309,7 @@
'product_promotions' => Promotions::forProduct($id),
'can_manage_product_promotions' => Permission::check('manage_orders'),
'return_url' => $this->base() . '/catalog/products',
'product_copy_url' => $this->base() . '/catalog/products/' . $id . '/copy',
'submit_url' => $this->base() . '/documents/' . $id,
));
}
@@ -1754,6 +1865,24 @@
AdminAssets::addScript('/modules/products/admin/assets/card-templates.js', 56);
}
protected function publicTemplateAssets()
{
$this->assets();
CodeEditor::useCodeMirror('htmlmixed');
AdminAssets::addStyle(ADMINX_BASE . '/modules/Themes/assets/view-overrides.css', 56);
AdminAssets::addScript(ADMINX_BASE . '/modules/Themes/assets/view-overrides.js', 56);
}
protected function publicTemplateGuard($requireThemePermission = true)
{
if (($error = $this->productGuard()) !== null) { return $error; }
if ($requireThemePermission && !Permission::check('manage_themes')) {
return $this->error('Для изменения публичной разметки требуется право управления темами', array(), 403);
}
return null;
}
protected function audit($action, $targetId = null, array $meta = array())
{
$user = Auth::user();
+196 -18
View File
@@ -5,11 +5,128 @@
overflow-y: hidden;
scrollbar-width: thin;
}
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
border-top: 1px solid var(--border-strong);
.catalog-tree {
position: relative;
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px;
overflow-x: hidden;
background: var(--background-subtle);
}
.catalog-tree-row {
grid-template-columns: 30px 22px minmax(180px, 1fr) 112px 70px 84px;
.catalog-tree.is-drag-active {
user-select: none;
}
.catalog-tree.is-order-saving::after {
position: absolute;
inset: 0;
z-index: 4;
content: '';
cursor: wait;
background: rgba(248, 250, 252, 0.36);
}
.catalog-tree > .catalog-tree-item {
width: calc(100% - var(--catalog-indent, 0px));
min-width: 0;
margin-left: var(--catalog-indent, 0px);
}
.catalog-tree-row,
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
display: grid;
grid-template-columns: 30px 30px minmax(180px, 1fr) auto 104px 258px;
align-items: center;
gap: 8px;
min-height: 54px;
padding: 7px 9px;
border: 0;
border-radius: var(--radius-md);
outline: 0;
background: var(--background-card);
box-shadow: inset 0 0 0 1px var(--border-default);
cursor: pointer;
transition-property: background-color, box-shadow, opacity;
transition-duration: 150ms;
}
.catalog-tree-row:hover {
background: var(--blue-50);
box-shadow: inset 0 0 0 1px var(--blue-300);
}
.catalog-tree-row.is-selected {
background: var(--blue-50);
box-shadow: inset 3px 0 var(--blue-500), inset 0 0 0 1px var(--blue-300);
}
.catalog-tree-position {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--background-muted);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.catalog-tree-meta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.catalog-tree-meta .badge {
white-space: nowrap;
}
.catalog-tree-count {
min-width: 42px;
color: var(--text-secondary);
font-size: 11px;
text-align: center;
}
.catalog-tree-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
min-width: 0;
}
.catalog-tree-actions .btn-icon {
width: 40px;
min-width: 40px;
height: 40px;
}
.catalog-tree-actions .btn-icon:active {
transform: scale(0.96);
}
.catalog-tree-actions .btn-icon:disabled {
opacity: 0.28;
cursor: default;
}
.catalog-action-level {
color: var(--blue-600);
}
.catalog-action-sibling {
color: var(--green-600);
}
.catalog-action-child {
color: var(--cyan-700);
}
.catalog-action-edit {
color: var(--blue-600);
}
.catalog-action-delete {
color: var(--red-600);
}
.catalog-drag {
border-radius: var(--radius-sm);
outline: 0;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.catalog-drag:hover,
.catalog-drag:focus-visible {
background: var(--background-muted);
color: var(--blue-600);
}
.catalog-tree-tools {
display: flex;
@@ -39,21 +156,53 @@
}
.catalog-tree-item.is-inactive > .catalog-tree-row {
background: var(--amber-50);
box-shadow: inset 3px 0 var(--amber-500);
box-shadow: inset 3px 0 var(--amber-500), inset 0 0 0 1px var(--amber-200);
}
.catalog-tree-item.is-drop-target > .catalog-tree-row {
background: var(--blue-50);
.catalog-tree-row.drag-over-top {
box-shadow: inset 0 2px var(--blue-500);
}
.catalog-tree-item.is-drop-target.is-drop-after > .catalog-tree-row {
.catalog-tree-row.drag-over-bottom {
box-shadow: inset 0 -2px var(--blue-500);
}
.catalog-tree-item.is-drop-target.is-drop-inside > .catalog-tree-row {
background: var(--blue-50);
box-shadow: inset 0 0 0 2px var(--blue-500);
.catalog-tree > .catalog-tree-item.is-dragging,
.catalog-tree > .catalog-tree-item.is-dragging-child {
display: none;
}
.catalog-tree-item.is-dragging > .catalog-tree-row {
opacity: 0.58;
.catalog-tree-placeholder {
min-height: 52px;
border: 1px dashed var(--blue-500);
border-radius: var(--radius-md);
background: var(--blue-50);
box-shadow: inset 0 0 0 3px rgba(59, 130, 246, 0.08);
}
.catalog-tree-ghost {
position: fixed;
top: 0;
left: 0;
z-index: 3000;
max-width: calc(100vw - 24px);
box-sizing: border-box;
pointer-events: none;
opacity: 0.96;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.2);
will-change: transform;
}
.catalog-tree-ghost .catalog-tree-meta,
.catalog-tree-ghost .catalog-tree-status,
.catalog-tree-ghost .catalog-tree-actions {
display: none;
}
.catalog-tree-ghost {
grid-template-columns: 30px 30px minmax(160px, 1fr);
}
.catalog-tree-dragging,
.catalog-tree-dragging * {
cursor: grabbing !important;
}
.catalog-tree.is-filtering .catalog-action-level,
.catalog-tree.is-filtering .catalog-drag {
opacity: 0.28;
pointer-events: none;
}
.catalog-item-form {
display: flex;
@@ -741,8 +890,12 @@
.catalog-behavior-grid {
grid-template-columns: 1fr;
}
.catalog-tree-row {
grid-template-columns: 30px 18px minmax(140px, 1fr) 60px 70px;
.catalog-tree-row,
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
grid-template-columns: 30px 30px minmax(140px, 1fr) 94px 258px;
}
.catalog-tree-meta {
display: none;
}
.catalog-tree-status > span {
display: none;
@@ -773,8 +926,33 @@
margin-top: 3px;
}
}
@media (max-width: 640px) {
.catalog-tree-row {
grid-template-columns: 28px minmax(120px, 1fr) 54px 76px;
@media (max-width: 760px) {
.catalog-tree {
padding: 9px;
}
.catalog-tree > .catalog-tree-item {
width: calc(100% - min(var(--catalog-indent, 0px), 80px));
margin-left: min(var(--catalog-indent, 0px), 80px);
}
.catalog-tree-row,
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
grid-template-columns: 28px 26px minmax(0, 1fr) auto;
gap: 6px;
padding: 8px;
}
.catalog-tree-name {
grid-column: 3;
}
.catalog-tree-status {
grid-column: 4;
}
.catalog-tree-actions {
grid-column: 1 / -1;
flex-wrap: nowrap;
}
.catalog-tree-actions .btn-icon {
width: 40px;
min-width: 40px;
height: 40px;
}
}
+263 -18
View File
@@ -2,7 +2,7 @@
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
Adminx.Catalog = {
itemForm: null, settingsForm: null, dragRow: null, dragParent: null, dragSnapshot: '', dragTarget: null, filterOrder: [], itemSaveTimer: null, settingsSaveTimer: null, conditionContext: null,
itemForm: null, settingsForm: null, dragItem: null, dragGroup: [], dragPlaceholder: null, dragGhost: null, dragPointerId: null, dragStartX: 0, dragStartY: 0, dragStarted: false, dragOrderSnapshot: '', orderSaving: false, filterOrder: [], itemSaveTimer: null, settingsSaveTimer: null, conditionContext: null,
init: function () {
this.itemForm = document.getElementById('catalogItemForm');
this.settingsForm = document.getElementById('catalogSettingsForm');
@@ -12,11 +12,19 @@
if (pageTab) { self.tab('[data-catalog-page-tab]', '[data-catalog-page-panel]', pageTab.getAttribute('data-catalog-page-tab')); }
var drawerTab = e.target.closest('[data-catalog-drawer-tab]');
if (drawerTab) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', drawerTab.getAttribute('data-catalog-drawer-tab')); }
if (e.target.closest('[data-catalog-item-new]')) { self.newItem(); }
if (e.target.closest('[data-catalog-item-new]')) { self.newItem(0); }
var indent = e.target.closest('[data-catalog-item-indent]');
if (indent) { self.changeItemLevel(indent.closest('[data-catalog-item]'), parseInt(indent.getAttribute('data-catalog-item-indent'), 10) || 0); return; }
var sibling = e.target.closest('[data-catalog-item-sibling]');
if (sibling) { var siblingNode = sibling.closest('[data-catalog-item]'); self.newItem(Number(siblingNode.getAttribute('data-parent-id')) || 0); return; }
var child = e.target.closest('[data-catalog-item-child]');
if (child) { self.newItem(Number(child.getAttribute('data-catalog-item-child')) || 0); return; }
var edit = e.target.closest('[data-catalog-item-edit]');
if (edit) { self.editItem(edit.getAttribute('data-catalog-item-edit')); }
if (edit) { self.editItem(edit.getAttribute('data-catalog-item-edit')); return; }
var del = e.target.closest('[data-catalog-item-delete]');
if (del) { self.deleteItem(del.getAttribute('data-catalog-item-delete')); }
if (del) { self.deleteItem(del.getAttribute('data-catalog-item-delete')); return; }
var treeRow = e.target.closest('[data-catalog-builder-item]');
if (treeRow && !e.target.closest('button, input, label, a, select')) { self.editItem(treeRow.getAttribute('data-catalog-builder-item')); return; }
if (e.target.closest('[data-catalog-document-pick]')) { self.openDocumentPicker(); }
if (e.target.closest('[data-catalog-document-clear]')) { self.setDocument(null); self.scheduleItemSave(); }
var conditionView = e.target.closest('[data-catalog-condition-view]');
@@ -45,10 +53,11 @@
if (this.settingsForm) { this.settingsForm.addEventListener('submit', function (e) { e.preventDefault(); self.saveSettings(false); }); this.updateSettingsGroups(); this.updateCommerceSettings(); }
var createForm = document.querySelector('[data-catalog-create]');
if (createForm) { createForm.addEventListener('submit', function (e) { e.preventDefault(); self.createCatalog(createForm); }); }
document.addEventListener('dragstart', function (e) { self.dragStart(e); });
document.addEventListener('dragover', function (e) { self.dragOver(e); });
document.addEventListener('drop', function (e) { if (self.dragRow) { e.preventDefault(); } });
document.addEventListener('dragend', function () { self.dragEnd(); });
document.addEventListener('pointerdown', function (e) { self.treeDragStart(e); });
document.addEventListener('pointermove', function (e) { self.treeDragOver(e); });
document.addEventListener('pointerup', function (e) { self.treeDrop(e); });
document.addEventListener('pointercancel', function () { self.treeDragEnd(); });
this.normalizeTreeHierarchy();
this.openRequestedItem();
},
base: function () { return (this.itemForm || this.settingsForm).getAttribute('data-base'); },
@@ -66,8 +75,11 @@
document.querySelectorAll(tabs).forEach(function (el) { var active = el.getAttribute(tabs.indexOf('drawer') >= 0 ? 'data-catalog-drawer-tab' : 'data-catalog-page-tab') === value; el.classList.toggle('is-active', active); el.setAttribute('aria-selected', active ? 'true' : 'false'); });
document.querySelectorAll(panels).forEach(function (el) { el.hidden = el.getAttribute(panels.indexOf('drawer') >= 0 ? 'data-catalog-drawer-panel' : 'data-catalog-page-panel') !== value; });
},
newItem: function () {
newItem: function (parentId) {
this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = ''; this.itemForm.querySelector('[name="status"]').checked = true;
this.itemForm.querySelectorAll('[name="parent_id"] option').forEach(function (option) { option.disabled = false; });
var parent = this.itemForm.querySelector('[name="parent_id"]');
if (parent) { parent.value = String(Number(parentId) || 0); }
var defaultFields = this.settingsValues('fields_default[]'), defaultFilters = this.settingsValues('filters_default[]');
this.checkValues('fields_use[]', defaultFields); this.checkValues('filters_use[]', defaultFilters);
defaultFilters.forEach(function (id) { var source = this.settingsForm.querySelector('[name="filter_style[' + id + ']"]'), target = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (source && target) { target.value = source.value; } }, this);
@@ -80,7 +92,7 @@
updateSettingsGroups: function () { if (!this.settingsForm) { return; } this.settingsForm.querySelectorAll('[data-catalog-settings-group]').forEach(function (group) { var name = group.getAttribute('data-catalog-settings-group'), selected = group.querySelectorAll('[name="' + name + '"]:checked').length, badge = group.querySelector('[data-catalog-settings-selected]'); group.querySelectorAll('.catalog-choice-row, .catalog-filter-row').forEach(function (row) { var input = row.querySelector('[name="' + name + '"]'); row.classList.toggle('is-selected', !!input && input.checked); }); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); },
updateCommerceSettings: function () { if (!this.settingsForm) { return; } var purpose = this.settingsForm.querySelector('[name="purpose"]'), visible = purpose && purpose.value === 'commerce'; this.settingsForm.querySelectorAll('[data-catalog-commerce-fields]').forEach(function (section) { section.hidden = !visible; }); },
editItem: function (id, requestedTab) {
var self = this; Adminx.Loader.show();
var self = this; this.markSelectedItem(id); Adminx.Loader.show();
fetch(this.base() + '/catalog/items/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) {
self.fill(payload.data || {}); Adminx.Drawer.open('catalogItemDrawer');
if (['main', 'fields', 'filters'].indexOf(requestedTab) >= 0) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', requestedTab); }
@@ -94,13 +106,19 @@
},
fill: function (item) {
this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = item.id || ''; this.itemForm.querySelector('[name="name"]').value = item.name || '';
this.itemForm.querySelectorAll('[name="parent_id"] option').forEach(function (option) { option.disabled = false; });
this.itemForm.querySelector('[name="parent_id"]').value = item.parent_id || 0; this.itemForm.querySelector('[name="status"]').checked = Number(item.status) === 1;
this.setDocument(item.document_id ? { id: item.document_id, title: item.document_title || '', alias: item.document_alias || '' } : null);
this.filterOrder = (item.filters_use || []).map(function (id) { return String(id); }); this.syncFilterOrderInput(); this.checkValues('fields_use[]', item.fields_use || []); this.checkValues('filters_use[]', item.filters_use || []);
Object.keys(item.filter_styles || {}).forEach(function (id) { var select = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (select) { select.value = item.filter_styles[id]; } }, this);
var own = this.itemForm.querySelector('[name="parent_id"] option[value="' + item.id + '"]'); if (own) { own.disabled = true; }
var treeItem = document.querySelector('[data-catalog-item][data-id="' + item.id + '"]');
this.treeBranch(treeItem).slice(1).forEach(function (child) {
var option = this.itemForm.querySelector('[name="parent_id"] option[value="' + child.getAttribute('data-id') + '"]');
if (option) { option.disabled = true; }
}, this);
this.renderConditionContext(item.condition_context || null);
document.querySelector('[data-catalog-drawer-title]').textContent = item.name || 'Раздел каталога'; this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); this.setState('item', '');
document.querySelector('[data-catalog-drawer-title]').textContent = item.name || 'Раздел каталога'; this.markSelectedItem(item.id); this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); this.setState('item', '');
},
checkValues: function (name, values) { var map = {}; values.forEach(function (id) { map[String(id)] = true; }); this.itemForm.querySelectorAll('[name="' + name + '"]').forEach(function (el) { el.checked = !!map[el.value]; }); },
updateCounts: function () { ['fields', 'filters'].forEach(function (type) { var count = this.itemForm.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var el = document.querySelector('[data-catalog-' + type + '-count]'); if (el) { el.textContent = count; } this.itemForm.querySelectorAll('[name="' + type + '_use[]"]').forEach(function (toggle) { var row = toggle.closest('.catalog-choice-row, .catalog-filter-row'); if (row) { row.classList.toggle('is-selected', toggle.checked); } }); this.itemForm.querySelectorAll('[data-catalog-field-group="' + type + '"]').forEach(function (group) { var selected = group.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var badge = group.querySelector('[data-catalog-group-selected]'); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); }, this); var orderButton = this.itemForm.querySelector('[data-catalog-filter-order]'); if (orderButton) { orderButton.disabled = this.filterOrder.length < 2; } this.renderConditionContext(this.conditionContext); },
@@ -160,7 +178,7 @@
},
saveSettings: function (auto) { var self = this; this.ajax(this.url() + '/settings', new FormData(this.settingsForm), function (payload) { if (auto) { self.setState('settings', 'Сохранено', 'ok'); } else { self.setState('settings', 'Сохранено', 'ok'); Adminx.Toast.show(payload.message, 'success'); } }, { quiet: !!auto, fail: function () { self.setState('settings', 'Не сохранено', 'error'); } }); },
setState: function (type, message, state) { var el = document.querySelector('[data-catalog-' + type + '-state]'); if (!el) { return; } el.textContent = message || ''; el.classList.toggle('is-ok', state === 'ok'); el.classList.toggle('is-error', state === 'error'); },
syncTreeRow: function (id) { var row = document.querySelector('[data-catalog-item][data-id="' + id + '"]'); if (!row) { return; } var toggle = row.querySelector('[data-catalog-item-status]'), active = this.itemForm.querySelector('[name="status"]').checked, filterCount = this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').length; if (toggle) { toggle.checked = active; this.updateTreeStatus(toggle); } row.setAttribute('data-filter-count', String(filterCount)); var count = row.querySelector('.catalog-tree-count'); if (count) { count.textContent = this.itemForm.querySelectorAll('[name="fields_use[]"]:checked').length + ' / ' + filterCount; } },
syncTreeRow: function (id) { var row = document.querySelector('[data-catalog-item][data-id="' + id + '"]'); if (!row) { return; } var toggle = row.querySelector('[data-catalog-item-status]'), active = this.itemForm.querySelector('[name="status"]').checked, filterCount = this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').length, name = row.querySelector('.catalog-tree-name b'); if (toggle) { toggle.checked = active; this.updateTreeStatus(toggle); } if (name) { name.textContent = this.itemForm.querySelector('[name="name"]').value || 'Без названия'; } row.setAttribute('data-filter-count', String(filterCount)); var count = row.querySelector('.catalog-tree-count'); if (count) { count.textContent = this.itemForm.querySelectorAll('[name="fields_use[]"]:checked').length + ' / ' + filterCount; } },
updateTreeStatus: function (input) { var item = input.closest('[data-catalog-item]'), active = input.checked, label = item.querySelector('[data-catalog-item-status-label]'), control = input.closest('.switch'); item.classList.toggle('is-inactive', !active); if (label) { label.textContent = active ? 'активен' : 'скрыт'; } input.setAttribute('aria-label', active ? 'Скрыть раздел' : 'Включить раздел'); if (control) { control.setAttribute('data-tooltip', active ? 'Скрыть раздел' : 'Включить раздел'); } },
setTreeStatus: function (input) { var self = this, id = input.getAttribute('data-catalog-item-status'), previous = !input.checked, data = new FormData(); this.updateTreeStatus(input); input.disabled = true; data.append('_csrf', this.csrf()); data.append('status', input.checked ? '1' : '0'); this.ajax(this.url() + '/items/' + encodeURIComponent(id) + '/status', data, function (payload) { input.checked = Number(payload.data.status) === 1; input.disabled = false; self.updateTreeStatus(input); }, { quiet: true, fail: function () { input.checked = previous; input.disabled = false; self.updateTreeStatus(input); } }); },
setDocument: function (item) { if (!this.itemForm) { return; } var input = this.itemForm.querySelector('[name="document_id"]'); var label = this.itemForm.querySelector('[data-catalog-document-label]'); var clear = this.itemForm.querySelector('[data-catalog-document-clear]'); var id = item && item.id ? Number(item.id) : 0; input.value = id || ''; if (label) { label.textContent = id ? ('#' + id + ' · ' + (item.title || item.alias || 'Без названия')) : 'Документ не выбран'; } if (clear) { clear.disabled = !id; } },
@@ -180,12 +198,239 @@
var self = this, run = function () { var data = new FormData(); data.append('_csrf', self.csrf()); self.ajax(self.url() + '/items/' + id + '/delete', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); window.location.reload(); }); };
if (Adminx.Confirm) { Adminx.Confirm.open({ kind: 'error', title: 'Удалить раздел?', message: 'Будут удалены также все вложенные разделы. Документы останутся.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', onConfirm: run }); } else if (confirm('Удалить раздел и все вложенные?')) { run(); }
},
search: function (value) { value = String(value || '').trim().toLowerCase(); document.querySelectorAll('[data-catalog-item]').forEach(function (row) { row.classList.toggle('is-filtered', value && row.textContent.toLowerCase().indexOf(value) < 0); }); },
dragStart: function (e) { var handle = e.target.closest('.catalog-drag[draggable="true"]'), row = handle ? handle.closest('[data-catalog-item]') : null; if (!row) { e.preventDefault(); return; } this.dragRow = row; this.dragParent = row.parentNode; this.dragSnapshot = this.siblingOrder(this.dragParent); row.classList.add('is-dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.getAttribute('data-id')); },
dragOver: function (e) { if (!this.dragRow) { return; } var target = e.target.closest('[data-catalog-item]'); if (!target || target === this.dragRow || this.dragRow.contains(target)) { return; } var row = target.querySelector(':scope > .catalog-tree-row'), rect = row.getBoundingClientRect(), ratio = (e.clientY - rect.top) / rect.height, mode = ratio < .28 ? 'before' : (ratio > .72 ? 'after' : 'inside'), destination = null, reference = null, parentId = 0; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dragTarget && this.dragTarget !== target) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } this.dragTarget = target; target.classList.add('is-drop-target'); target.classList.toggle('is-drop-after', mode === 'after'); target.classList.toggle('is-drop-inside', mode === 'inside'); if (mode === 'inside') { destination = target.querySelector(':scope > .catalog-tree-children'); if (!destination) { destination = document.createElement('ol'); destination.className = 'catalog-tree-children'; target.appendChild(destination); } parentId = Number(target.getAttribute('data-id')); } else { destination = target.parentNode; reference = mode === 'before' ? target : target.nextSibling; parentId = Number(target.getAttribute('data-parent-id')); } if (destination !== this.dragRow.parentNode || (reference !== this.dragRow && this.dragRow.nextSibling !== reference)) { destination.insertBefore(this.dragRow, reference); this.dragRow.setAttribute('data-parent-id', String(parentId)); } },
dragEnd: function () { if (!this.dragRow) { return; } var changed = this.dragSnapshot !== this.siblingOrder(this.dragParent); this.dragRow.classList.remove('is-dragging'); if (this.dragTarget) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } document.querySelectorAll('.catalog-tree-children').forEach(function (list) { if (!list.querySelector(':scope > [data-catalog-item]')) { list.remove(); } }); this.dragRow = null; this.dragParent = null; this.dragTarget = null; this.dragSnapshot = ''; if (changed) { this.saveOrder(); } },
siblingOrder: function (parent) { return Array.prototype.filter.call(parent ? parent.children : [], function (el) { return el.matches('[data-catalog-item]'); }).map(function (el) { return el.getAttribute('data-id'); }).join(','); },
saveOrder: function () { var rows = []; document.querySelectorAll('[data-catalog-item]').forEach(function (row) { var siblings = Array.prototype.filter.call(row.parentNode.children, function (el) { return el.matches('[data-catalog-item]'); }); rows.push({ id: Number(row.getAttribute('data-id')), parent_id: Number(row.getAttribute('data-parent-id')), position: siblings.indexOf(row) }); }); var data = new FormData(); data.append('_csrf', this.csrf()); data.append('order', JSON.stringify(rows)); this.ajax(this.url() + '/reorder', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); }); },
markSelectedItem: function (id) {
document.querySelectorAll('[data-catalog-builder-item]').forEach(function (row) {
row.classList.toggle('is-selected', String(row.getAttribute('data-catalog-builder-item')) === String(id));
});
},
treeRoot: function () { return document.querySelector('[data-catalog-tree]'); },
treeNodes: function () {
var root = this.treeRoot();
return root ? Array.prototype.filter.call(root.children, function (node) { return node.matches('[data-catalog-item]'); }) : [];
},
search: function (value) {
value = String(value || '').trim().toLowerCase();
var nodes = this.treeNodes(), byId = {}, visible = {};
nodes.forEach(function (node) { byId[String(node.getAttribute('data-id'))] = node; node.classList.add('is-filtered'); });
if (!value) {
nodes.forEach(function (node) { node.classList.remove('is-filtered'); });
} else {
nodes.forEach(function (node) {
var name = node.querySelector('.catalog-tree-name');
if (!name || name.textContent.toLowerCase().indexOf(value) < 0) { return; }
var current = node;
while (current && !visible[current.getAttribute('data-id')]) {
visible[current.getAttribute('data-id')] = true;
current = byId[String(current.getAttribute('data-parent-id'))] || null;
}
});
Object.keys(visible).forEach(function (id) { if (byId[id]) { byId[id].classList.remove('is-filtered'); } });
}
var root = this.treeRoot();
if (root) { root.classList.toggle('is-filtering', !!value); }
},
treeDragStart: function (e) {
var handle = e.target.closest('[data-catalog-drag-handle]');
var root = handle ? handle.closest('[data-catalog-tree]') : null;
if (!handle || !root || handle.disabled || this.orderSaving || root.classList.contains('is-filtering') || (typeof e.button === 'number' && e.button !== 0)) { return; }
this.dragItem = handle.closest('[data-catalog-item]');
if (!this.dragItem) { return; }
this.dragPointerId = e.pointerId;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStarted = false;
this.dragOrderSnapshot = this.treeOrderSignature();
if (handle.setPointerCapture) { handle.setPointerCapture(e.pointerId); }
e.preventDefault();
},
beginTreeDrag: function (e) {
if (!this.dragItem || this.dragStarted) { return; }
var row = this.dragItem.querySelector('[data-catalog-builder-item]');
var firstRect = this.dragItem.getBoundingClientRect();
var rowRect = row.getBoundingClientRect();
this.dragStarted = true;
this.dragGroup = this.treeBranch(this.dragItem);
this.dragPlaceholder = document.createElement('li');
this.dragPlaceholder.className = 'catalog-tree-placeholder';
this.dragPlaceholder.setAttribute('aria-hidden', 'true');
var lastRect = this.dragGroup[this.dragGroup.length - 1].getBoundingClientRect();
this.dragPlaceholder.style.height = Math.max(52, lastRect.bottom - firstRect.top) + 'px';
this.dragItem.parentNode.insertBefore(this.dragPlaceholder, this.dragItem);
this.dragGhost = row.cloneNode(true);
this.dragGhost.classList.remove('is-selected');
this.dragGhost.classList.add('catalog-tree-ghost');
this.dragGhost.style.width = Math.min(rowRect.width, 520, Math.max(1, window.innerWidth - 24)) + 'px';
document.body.appendChild(this.dragGhost);
this.positionTreeDragGhost(e);
document.body.classList.add('catalog-tree-dragging');
this.treeRoot().classList.add('is-drag-active');
this.dragGroup.forEach(function (node, index) { node.classList.add(index === 0 ? 'is-dragging' : 'is-dragging-child'); });
},
treeDragOver: function (e) {
if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; }
if (!this.dragStarted) {
if (Math.abs(e.clientX - this.dragStartX) < 5 && Math.abs(e.clientY - this.dragStartY) < 5) { return; }
this.beginTreeDrag(e);
}
e.preventDefault();
this.positionTreeDragGhost(e);
this.scrollTree(e.clientY);
var target = null, bottom = false, self = this;
this.treeNodes().some(function (node) {
if (self.dragGroup.indexOf(node) !== -1 || node.classList.contains('is-filtered')) { return false; }
var candidate = node.querySelector('[data-catalog-builder-item]');
var rect = candidate ? candidate.getBoundingClientRect() : null;
if (!rect || e.clientY >= rect.bottom) { return false; }
target = node;
bottom = e.clientY > rect.top + rect.height / 2;
return true;
});
document.querySelectorAll('.catalog-tree-row.drag-over-top, .catalog-tree-row.drag-over-bottom').forEach(function (row) { row.classList.remove('drag-over-top', 'drag-over-bottom'); });
if (!target) { this.treeRoot().appendChild(this.dragPlaceholder); return; }
var targetRow = target.querySelector('[data-catalog-builder-item]');
var branch = this.treeBranch(target);
var anchor = bottom ? branch[branch.length - 1].nextSibling : target;
if (anchor !== this.dragPlaceholder) { target.parentNode.insertBefore(this.dragPlaceholder, anchor); }
targetRow.classList.toggle('drag-over-top', !bottom);
targetRow.classList.toggle('drag-over-bottom', bottom);
},
treeDrop: function (e) {
if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; }
e.preventDefault();
if (!this.dragStarted || !this.dragPlaceholder || !this.dragPlaceholder.parentNode) { this.treeDragEnd(); return; }
var placeholder = this.dragPlaceholder;
this.dragGroup.forEach(function (node) { placeholder.parentNode.insertBefore(node, placeholder); });
placeholder.parentNode.removeChild(placeholder);
this.dragPlaceholder = null;
this.normalizeTreeHierarchy();
var changed = this.treeOrderSignature() !== this.dragOrderSnapshot;
this.treeDragEnd();
if (changed) { this.persistTreeOrder(); }
},
treeDragEnd: function () {
this.dragGroup.forEach(function (node) { node.classList.remove('is-dragging', 'is-dragging-child'); });
if (this.dragPlaceholder && this.dragPlaceholder.parentNode) { this.dragPlaceholder.parentNode.removeChild(this.dragPlaceholder); }
document.querySelectorAll('.catalog-tree-row.drag-over-top, .catalog-tree-row.drag-over-bottom').forEach(function (row) { row.classList.remove('drag-over-top', 'drag-over-bottom'); });
var root = this.treeRoot();
if (root) { root.classList.remove('is-drag-active'); }
if (this.dragGhost && this.dragGhost.parentNode) { this.dragGhost.parentNode.removeChild(this.dragGhost); }
document.body.classList.remove('catalog-tree-dragging');
this.dragItem = null;
this.dragGroup = [];
this.dragPlaceholder = null;
this.dragGhost = null;
this.dragPointerId = null;
this.dragStarted = false;
this.dragOrderSnapshot = '';
},
positionTreeDragGhost: function (e) {
if (!this.dragGhost) { return; }
var left = Math.min(e.clientX + 14, window.innerWidth - this.dragGhost.offsetWidth - 12);
var top = Math.min(e.clientY + 12, window.innerHeight - this.dragGhost.offsetHeight - 12);
this.dragGhost.style.transform = 'translate3d(' + Math.max(12, left) + 'px,' + Math.max(12, top) + 'px,0)';
},
scrollTree: function (clientY) {
var edge = 72;
if (clientY < edge) { window.scrollBy(0, -Math.ceil((edge - clientY) / 5)); }
if (clientY > window.innerHeight - edge) { window.scrollBy(0, Math.ceil((clientY - window.innerHeight + edge) / 5)); }
},
treeOrderSignature: function () {
return this.treeNodes().map(function (node) { return node.getAttribute('data-id') + ':' + node.getAttribute('data-level'); }).join('|');
},
treeBranch: function (node) {
if (!node) { return []; }
var branch = [node], level = parseInt(node.getAttribute('data-level'), 10) || 0, next = node.nextElementSibling;
while (next) {
if (next === this.dragPlaceholder) { next = next.nextElementSibling; continue; }
if (!next.matches('[data-catalog-item]') || (parseInt(next.getAttribute('data-level'), 10) || 0) <= level) { break; }
branch.push(next);
next = next.nextElementSibling;
}
return branch;
},
canIndentItem: function (node) {
var level = parseInt(node.getAttribute('data-level'), 10) || 0, previous = node.previousElementSibling;
while (previous) {
if (!previous.matches('[data-catalog-item]')) { previous = previous.previousElementSibling; continue; }
var previousLevel = parseInt(previous.getAttribute('data-level'), 10) || 0;
if (previousLevel < level) { return false; }
if (previousLevel === level) { return true; }
previous = previous.previousElementSibling;
}
return false;
},
changeItemLevel: function (node, delta) {
if (!node || !delta || this.orderSaving) { return; }
var root = this.treeRoot();
if (root && root.classList.contains('is-filtering')) { Adminx.Toast.show('Очистите поиск перед изменением структуры', 'info'); return; }
var level = parseInt(node.getAttribute('data-level'), 10) || 0;
if (delta > 0 && !this.canIndentItem(node)) { return; }
if (delta < 0 && level === 0) { return; }
var shift = delta > 0 ? 1 : -1;
this.treeBranch(node).forEach(function (branchNode) {
var branchLevel = parseInt(branchNode.getAttribute('data-level'), 10) || 0;
branchNode.setAttribute('data-level', String(Math.max(0, branchLevel + shift)));
});
this.normalizeTreeHierarchy();
this.persistTreeOrder();
},
normalizeTreeHierarchy: function () {
var nodes = this.treeNodes(), parents = [], positions = {}, previousLevel = 0, self = this;
nodes.forEach(function (node, index) {
var level = Math.max(0, parseInt(node.getAttribute('data-level'), 10) || 0);
if (index === 0) { level = 0; }
if (level > previousLevel + 1) { level = previousLevel + 1; }
var parentId = level > 0 && parents[level - 1] ? parents[level - 1] : 0;
if (level > 0 && !parentId) { level = 0; parentId = 0; }
var id = parseInt(node.getAttribute('data-id'), 10) || 0;
var key = String(parentId);
var position = positions[key] || 0;
positions[key] = position + 1;
parents[level] = id;
parents.length = level + 1;
previousLevel = level;
node.setAttribute('data-level', String(level));
node.setAttribute('data-parent-id', String(parentId));
node.setAttribute('data-position', String(position));
node.style.setProperty('--catalog-indent', Math.min(level, 10) * 28 + 'px');
var number = node.querySelector('.catalog-tree-position');
if (number) { number.textContent = String(position + 1); }
var badge = node.querySelector('[data-catalog-level-badge]');
if (badge) { badge.textContent = 'уровень ' + (level + 1); }
});
nodes.forEach(function (node) {
var level = parseInt(node.getAttribute('data-level'), 10) || 0;
var decrease = node.querySelector('[data-catalog-item-indent="-1"]');
var increase = node.querySelector('[data-catalog-item-indent="1"]');
if (decrease) { decrease.disabled = level === 0; }
if (increase) { increase.disabled = !self.canIndentItem(node); }
});
},
persistTreeOrder: function () {
if (this.orderSaving) { return; }
this.normalizeTreeHierarchy();
var rows = this.treeNodes().map(function (node) {
return {
id: Number(node.getAttribute('data-id')),
parent_id: Number(node.getAttribute('data-parent-id')),
position: Number(node.getAttribute('data-position'))
};
});
var data = new FormData(), self = this, root = this.treeRoot();
data.append('_csrf', this.csrf());
data.append('order', JSON.stringify(rows));
this.orderSaving = true;
if (root) { root.classList.add('is-order-saving'); }
this.ajax(this.url() + '/reorder', data, function (payload) {
self.orderSaving = false;
if (root) { root.classList.remove('is-order-saving'); }
Adminx.Toast.show(payload.message || 'Порядок сохранён', 'success');
}, { quiet: true, fail: function () {
self.orderSaving = false;
if (root) { root.classList.remove('is-order-saving'); }
setTimeout(function () { window.location.reload(); }, 400);
} });
},
ajax: function (url, data, done, options) { options = options || {}; if (!options.quiet) { Adminx.Loader.show(); } fetch(url, { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) { if (!payload.success) { throw payload; } done(payload); }).catch(function (payload) { if (options.fail) { options.fail(payload); } Adminx.Catalog.error(payload); }).finally(function () { if (!options.quiet) { Adminx.Loader.hide(); } }); },
json: function (res) { return res.json().then(function (payload) { if (!res.ok) { throw payload; } return payload; }); },
esc: function (value) { var node = document.createElement('div'); node.textContent = String(value == null ? '' : value); return node.innerHTML; },
+1 -1
View File
@@ -14,7 +14,7 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
return array(
'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.2.0',
'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.3.2',
'permissions' => array('key' => 'catalog', 'items' => array(
array(
'code' => 'view_catalog',
+10 -9
View File
@@ -1,18 +1,19 @@
{% extends '@adminx/main.twig' %}
{% block title %}{{ catalog.rubric_title }} · Каталог{% endblock %}
{% macro rows(items, can_manage) %}
{% macro rows(items, can_manage, level) %}
{% set level = level|default(0) %}
{% for item in items %}
<li class="catalog-tree-item{{ item.status ? '' : ' is-inactive' }}" data-catalog-item data-id="{{ item.id }}" data-parent-id="{{ item.parent_id }}" data-filter-count="{{ item.filters_use|length }}">
<div class="catalog-tree-row">
<button class="catalog-drag" type="button" draggable="{{ can_manage ? 'true' : 'false' }}" aria-label="Перетащить" data-tooltip="Изменить порядок"><i class="ti ti-grip-vertical"></i></button>
<span class="catalog-tree-branch"><i class="ti ti-corner-down-right"></i></span>
<li class="catalog-tree-item{{ item.status ? '' : ' is-inactive' }}" data-catalog-item data-id="{{ item.id }}" data-parent-id="{{ item.parent_id }}" data-position="{{ item.position }}" data-level="{{ level }}" data-filter-count="{{ item.filters_use|length }}" style="--catalog-indent:{{ level > 10 ? 280 : level * 28 }}px">
<div class="catalog-tree-row" data-catalog-builder-item="{{ item.id }}">
<button class="catalog-drag" type="button" aria-label="Перетащить раздел" data-tooltip="Перетащить раздел вместе с подразделами" data-catalog-drag-handle{{ can_manage ? '' : ' disabled' }}><i class="ti ti-grip-vertical"></i></button>
<span class="catalog-tree-position" data-tooltip="Позиция внутри уровня">{{ item.position + 1 }}</span>
<div class="catalog-tree-name"><b>{{ item.name }}</b><small>#{{ item.id }}{% if item.document_id %} · документ #{{ item.document_id }}{% endif %}</small></div>
<div class="catalog-tree-meta"><span class="badge badge-gray" data-catalog-level-badge>уровень {{ level + 1 }}</span><span class="catalog-tree-count" data-tooltip="Поля / фильтры">{{ item.fields_use|length }} / {{ item.filters_use|length }}</span></div>
<div class="catalog-tree-status"><span data-catalog-item-status-label>{{ item.status ? 'активен' : 'скрыт' }}</span><label class="switch" data-tooltip="{{ item.status ? 'Скрыть раздел' : 'Включить раздел' }}"><input type="checkbox" data-catalog-item-status="{{ item.id }}"{{ item.status ? ' checked' : '' }} aria-label="{{ item.status ? 'Скрыть раздел' : 'Включить раздел' }}"></label></div>
<span class="catalog-tree-count" data-tooltip="Поля / фильтры">{{ item.fields_use|length }} / {{ item.filters_use|length }}</span>
{% if can_manage %}<div class="catalog-tree-actions"><button class="btn btn-ghost btn-icon btn-sm catalog-action-edit" type="button" data-catalog-item-edit="{{ item.id }}" data-tooltip="Редактировать"><i class="ti ti-edit"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-delete" type="button" data-catalog-item-delete="{{ item.id }}" data-tooltip="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}
{% if can_manage %}<div class="catalog-tree-actions"><button class="btn btn-ghost btn-icon btn-sm catalog-action-level" type="button" data-catalog-item-indent="-1" data-tooltip="Поднять на уровень выше"{{ level == 0 ? ' disabled' : '' }}><i class="ti ti-arrow-left"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-level" type="button" data-catalog-item-indent="1" data-tooltip="Сделать подразделом"><i class="ti ti-arrow-right"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-sibling" type="button" data-catalog-item-sibling="{{ item.id }}" data-tooltip="Добавить рядом"><i class="ti ti-row-insert-bottom"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-child" type="button" data-catalog-item-child="{{ item.id }}" data-tooltip="Добавить подраздел"><i class="ti ti-subtask"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-edit" type="button" data-catalog-item-edit="{{ item.id }}" data-tooltip="Редактировать"><i class="ti ti-edit"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-delete" type="button" data-catalog-item-delete="{{ item.id }}" data-tooltip="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}
</div>
{% if item.children %}<ol class="catalog-tree-children">{{ _self.rows(item.children, can_manage) }}</ol>{% endif %}
</li>
{% if item.children %}{{ _self.rows(item.children, can_manage, level + 1) }}{% endif %}
{% endfor %}
{% endmacro %}
{% block content %}
@@ -25,7 +26,7 @@
<div class="section-header catalog-panel-header"><div class="section-icon"><i class="ti ti-list-tree"></i></div><div><div class="section-eyebrow">Структура</div><h2>Разделы каталога</h2><p class="section-desc">Порядок и вложенность используются в каталожном поле документа.</p></div></div>
<div class="card catalog-card">
<div class="catalog-section-head"><div class="catalog-section-title"><span class="icon-tile catalog-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-tree"></i></span><div><h2>Дерево разделов</h2><p class="text-secondary">Перетаскивайте строки за маркер слева.</p></div></div><div class="catalog-tree-tools"><label class="input-wrap catalog-tree-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-catalog-tree-search></label>{% if can_manage %}<button class="btn btn-secondary" type="button" data-catalog-recompile-all><i class="ti ti-refresh"></i>Пересобрать фильтры</button>{% endif %}</div></div>
<ol class="catalog-tree" data-catalog-tree>{{ _self.rows(tree, can_manage) }}</ol>
<ol class="catalog-tree" data-catalog-tree>{{ _self.rows(tree, can_manage, 0) }}</ol>
{% if not tree %}<div class="empty-state">Разделов пока нет.</div>{% endif %}
</div>
</section>
+1 -1
View File
@@ -31,7 +31,7 @@
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
return !defined('ENV_CMS') || strtolower((string) ENV_CMS) !== 'production';
return false;
}
public static function execute($code)
+2 -2
View File
@@ -20,7 +20,7 @@
return array(
'code' => 'console',
'name' => 'PHP-консоль',
'version' => '0.1.0',
'version' => '0.1.1',
'registry' => array('dynamic' => true),
);
}
@@ -28,7 +28,7 @@
return array(
'code' => 'console',
'name' => 'PHP-консоль',
'version' => '0.1.0',
'version' => '0.1.1',
'registry' => array('dynamic' => true),
'permissions' => array(
'key' => 'console',
+93 -5
View File
@@ -19,26 +19,97 @@
use App\Common\AdminAssets;
use App\Adminx\Support\Roles;
use App\Adminx\Support\CodeEditor;
use App\Adminx\Support\SavedViews;
use App\Common\Auth;
use App\Common\Controller as BaseController;
use App\Common\Permission;
use App\Helpers\Request;
use App\Helpers\Response;
use App\Helpers\Csv;
use App\Common\AuditLog;
class Controller extends BaseController
{
public function exportCustomers(array $params = array())
{
if (!Permission::check('view_customers')) { Response::forbidden(); return ''; }
$q = Request::getStr('q', '');
AuditLog::record('customers.exported', array('actor_id' => Auth::id(), 'target_type' => 'customers', 'meta' => array('q' => $q)));
Csv::download('customers_' . date('Ymd_His') . '.csv', array('ID', 'Имя', 'Фамилия', 'Логин', 'Email', 'Телефон', 'Компания', 'Статус', 'Регистрация', 'Последний вход'), function ($write) use ($q) {
$before = 0;
do {
$rows = Model::exportChunk($q, $before, 500);
foreach ($rows as $row) { $before = (int) $row['id']; $write(array($row['id'], $row['firstname'], $row['lastname'], $row['user_name'], $row['email'], $row['phone'], $row['company'], (string) $row['status'] === '1' ? 'Активен' : 'Отключён', (int) $row['reg_time'] > 0 ? date('d.m.Y H:i', (int) $row['reg_time']) : '', (int) $row['last_visit'] > 0 ? date('d.m.Y H:i', (int) $row['last_visit']) : ''));
}
} while (count($rows) === 500);
});
return '';
}
public function index(array $params=array())
{
if(!Permission::check('view_customers')){Response::forbidden();return '';}AdminAssets::addStyle($this->base().'/modules/Customers/assets/customers.css',50);AdminAssets::addScript($this->base().'/modules/Customers/assets/customers.js',50);CodeEditor::useCodeMirror('htmlmixed');
$oauthModules=Model::oauthModules();foreach($oauthModules as &$oauthModule){$oauthModule['can_open']=Permission::check($oauthModule['permission']);}unset($oauthModule);
$tab=Request::getStr('tab','customers');if(!in_array($tab,array('customers','fields','auth','pages'),true)){$tab='customers';}return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers(Request::getStr('q','')),'fields'=>Model::fields(),'stats'=>Model::stats(),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'oauth_modules'=>$oauthModules,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>Request::getStr('q',''),'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users')));
$authMethods=Model::authMethods();$authMethodStats=array('installed'=>0,'active'=>0);foreach($authMethods as &$authMethod){$authMethod['can_open']=$authMethod['installed']?Permission::check($authMethod['permission']):Permission::check('view_modules');$authMethod['action_url']=$authMethod['installed']?$authMethod['url']:'/modules';$authMethod['action_label']=$authMethod['installed']?'Настроить':'Открыть модули';if($authMethod['installed']){$authMethodStats['installed']++;}if($authMethod['active']){$authMethodStats['active']++;}}unset($authMethod);
$tab=Request::getStr('tab','center');if(!in_array($tab,array('center','customers','fields','auth','pages'),true)){$tab='center';}$q=Request::getStr('q','');$segment=Request::getStr('segment','all');return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers($q),'fields'=>Model::fields(),'stats'=>Model::stats(),'center_customers'=>$tab==='center'?CustomerCenter::listing($q,$segment):array(),'center_stats'=>$tab==='center'?CustomerCenter::stats():array(),'center_segments'=>CustomerCenter::segments(),'duplicate_groups'=>$tab==='center'?CustomerCenter::duplicateGroups():array(),'segment'=>$segment,'saved_views'=>SavedViews::all('customers_center',Auth::id(),array('q','segment')),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'auth_methods'=>$authMethods,'auth_method_stats'=>$authMethodStats,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>$q,'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users'),'current_public_user_id'=>Model::publicIdForSystem(Auth::id())));
}
public function saveSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
$filters = json_decode(Request::postStr('filters', '{}'), true);
if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); }
try { $views = SavedViews::save('customers_center', Auth::id(), Request::postStr('title', ''), $filters, array('q', 'segment')); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('Представление сохранено', array('data' => array('views' => $views)));
}
public function deleteSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
try { $views = SavedViews::delete('customers_center', Auth::id(), isset($params['id']) ? $params['id'] : '', array('q', 'segment')); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); }
return $this->success('Представление удалено', array('data' => array('views' => $views)));
}
protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_customers') ? null : $this->error('Недостаточно прав', array(), 403); }
public function centerCustomer(array $params = array())
{
if (!Permission::check('view_customers')) { return $this->error('Недостаточно прав', array(), 403); }
$customer = CustomerCenter::detail(isset($params['id']) ? (int) $params['id'] : 0, Auth::id());
if (!$customer) { return $this->error('Пользователь не найден', array(), 404); }
return $this->success('', array('html' => array('detail' => $this->render('@customers/customer-center-detail.twig', array('customer' => $customer, 'can_manage' => Permission::check('manage_customers'))))));
}
public function addCustomerNote(array $params = array())
{
if (($error = $this->guard()) !== null) { return $error; }
try { CustomerCenter::addNote(isset($params['id']) ? (int) $params['id'] : 0, Auth::id(), Request::postStr('note', '')); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); }
catch (\Throwable $e) { error_log('Customer note: ' . $e->getMessage()); return $this->error('Не удалось добавить заметку', array(), 500); }
return $this->success('Заметка добавлена');
}
public function mergeCustomers(array $params = array())
{
if (($error = $this->guard()) !== null) { return $error; }
try { $customer = CustomerCenter::merge(Request::postInt('target_id', 0), Request::postInt('source_id', 0), Auth::id()); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); }
catch (\Throwable $e) { error_log('Customer merge: ' . $e->getMessage()); return $this->error('Не удалось объединить аккаунты', array(), 500); }
return $this->success('Аккаунты объединены', array('data' => array('id' => (int) $customer['user']['id']), 'reload' => true));
}
public function toggle(array $params=array())
{
if(($e=$this->guard())!==null){return $e;}
try{$active=Model::toggle(isset($params['id'])?$params['id']:0,Auth::id());}catch(\InvalidArgumentException $e){return $this->error($e->getMessage(),array(),422);}
return $this->success($active?'Пользователь включён':'Пользователь отключён');
}
public function toggle(array $params=array()){if(($e=$this->guard())!==null){return $e;}return $this->success(Model::toggle(isset($params['id'])?$params['id']:0)?'Пользователь включён':'Пользователь отключён');}
public function customer(array $params=array())
{
if(!Permission::check('view_customers')){return $this->error('Недостаточно прав',array(),403);}
$customer=Model::customer(isset($params['id'])?$params['id']:0);
$customer=Model::customer(isset($params['id'])?$params['id']:0,Auth::id());
return $customer?$this->success('',array('data'=>$customer)):$this->error('Пользователь не найден',array(),404);
}
@@ -46,11 +117,28 @@
{
if(($e=$this->guard())!==null){return $e;}
$id=isset($params['id'])?(int)$params['id']:0;$input=Request::postAll();
if(!Permission::check('manage_users')){$current=Model::customer($id);$input['admin_access']=!empty($current['system']['is_active'])?'1':'';$input['admin_role']=!empty($current['system']['role'])?(string)$current['system']['role']:'manager';}
if(!Permission::check('manage_users')){$current=Model::customer($id,Auth::id());$input['admin_access']=!empty($current['system']['is_active'])?'1':'';$input['admin_role']=!empty($current['system']['role'])?(string)$current['system']['role']:'manager';}
try{$customer=Model::updateCustomer($id,$input,Auth::id());}catch(\InvalidArgumentException $e){return $this->error($e->getMessage(),array(),422);}catch(\Throwable $e){return $this->error('Не удалось сохранить пользователя',array(),500);}
return $this->success('Профиль пользователя сохранён',array('data'=>$customer));
}
public function deleteCustomer(array $params = array())
{
if (($e = $this->guard()) !== null) {
return $e;
}
try {
Model::deleteCustomer(isset($params['id']) ? $params['id'] : 0, Auth::id());
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), array(), 422);
} catch (\Throwable $e) {
return $this->error('Не удалось удалить пользователя', array(), 500);
}
return $this->success('Пользователь удалён', array('reload' => true));
}
public function saveField(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$id=Model::saveField(isset($params['id'])?$params['id']:0,Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Поле сохранено',array('data'=>array('id'=>$id),'reload'=>true));}
public function deleteField(array $params=array()){if(($e=$this->guard())!==null){return $e;}Model::deleteField(isset($params['id'])?$params['id']:0);return $this->success('Поле удалено',array('reload'=>true));}
public function toggleField(array $params=array()){if(($e=$this->guard())!==null){return $e;}$active=Model::toggleField(isset($params['id'])?$params['id']:0);return $this->success($active?'Поле включено':'Поле скрыто',array('data'=>array('is_active'=>$active?1:0)));}
+371
View File
@@ -0,0 +1,371 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Customers/CustomerCenter.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Customers;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Common\SystemTables;
use App\Adminx\Support\AdminLocale;
use App\Content\BasketTables;
use App\Content\ContactsTables;
use App\Content\PublicUserTables;
use DB;
/** Read model and safe merge operations for the customer workspace. */
class CustomerCenter
{
public static function listing($query = '', $segment = 'all', $limit = 300)
{
$query = mb_substr(trim((string) $query), 0, 190, 'UTF-8');
$segment = isset(self::segments()[$segment]) ? $segment : 'all';
$limit = max(1, min(500, (int) $limit));
$users = PublicUserTables::table('users');
$orders = BasketTables::table('module_basket_history');
$hasOrders = DatabaseSchema::tableExists($orders);
$orderJoin = $hasOrders
? ' LEFT JOIN (SELECT order_user_id,COUNT(*) orders_count,SUM(order_total) orders_total,MAX(order_published) last_order_at'
. ' FROM ' . $orders . ' WHERE order_user_id>0 GROUP BY order_user_id) orders ON orders.order_user_id=u.Id'
: '';
$sql = 'SELECT u.Id AS id,u.email,u.firstname,u.lastname,u.user_name,u.phone,u.company,u.status,u.reg_time,u.last_visit,'
. ($orderJoin !== '' ? 'COALESCE(orders.orders_count,0)' : '0') . ' orders_count,'
. ($orderJoin !== '' ? 'COALESCE(orders.orders_total,0)' : '0') . ' orders_total,'
. ($orderJoin !== '' ? 'COALESCE(orders.last_order_at,0)' : '0') . ' last_order_at'
. ' FROM ' . $users . ' u' . $orderJoin . ' WHERE u.deleted!=%s';
$args = array('1');
if ($query !== '') {
$sql .= ' AND (u.email LIKE %ss OR u.firstname LIKE %ss OR u.lastname LIKE %ss OR u.user_name LIKE %ss OR u.phone LIKE %ss OR u.company LIKE %ss';
$args = array_merge($args, array($query, $query, $query, $query, $query, $query));
if (ctype_digit($query)) { $sql .= ' OR u.Id=%i'; $args[] = (int) $query; }
$sql .= ')';
}
$now = time();
if ($segment === 'new') { $sql .= ' AND u.reg_time>=%i'; $args[] = $now - 30 * 86400; }
elseif ($segment === 'buyers') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_count,0)>0' : ' AND 1=0'; }
elseif ($segment === 'repeat') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_count,0)>=2' : ' AND 1=0'; }
elseif ($segment === 'vip') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_total,0)>=100000' : ' AND 1=0'; }
elseif ($segment === 'inactive') { $sql .= ' AND (u.last_visit=0 OR u.last_visit<%i)'; $args[] = $now - 180 * 86400; }
elseif ($segment === 'without_orders' && $hasOrders) { $sql .= ' AND COALESCE(orders.orders_count,0)=0'; }
$sql .= ' ORDER BY orders_total DESC,last_order_at DESC,u.Id DESC LIMIT ' . $limit;
$rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array();
foreach ($rows as &$row) {
$row['id'] = (int) $row['id'];
$row['orders_count'] = (int) $row['orders_count'];
$row['orders_total'] = (float) $row['orders_total'];
$row['last_order_at'] = (int) $row['last_order_at'];
$row['segment_codes'] = self::segmentCodes($row);
}
unset($row);
return $rows;
}
public static function stats()
{
$users = PublicUserTables::table('users');
$orders = BasketTables::table('module_basket_history');
$result = array(
'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $users . ' WHERE deleted!=%s', '1')->getValue(),
'buyers' => 0, 'repeat' => 0, 'duplicates' => count(self::duplicateGroups()),
);
if (DatabaseSchema::tableExists($orders)) {
$result['buyers'] = (int) DB::query('SELECT COUNT(DISTINCT order_user_id) FROM ' . $orders . ' WHERE order_user_id>0')->getValue();
$result['repeat'] = (int) DB::query('SELECT COUNT(*) FROM (SELECT order_user_id FROM ' . $orders . ' WHERE order_user_id>0 GROUP BY order_user_id HAVING COUNT(*)>=2) repeated')->getValue();
}
return $result;
}
public static function segments()
{
$segments = array('all' => 'Все', 'new' => 'Новые', 'buyers' => 'С заказами', 'repeat' => 'Повторные', 'vip' => 'От 100 000 ₽', 'inactive' => 'Неактивные', 'without_orders' => 'Без заказов');
foreach ($segments as &$label) { $label = AdminLocale::translateMarkup($label); }
unset($label);
return $segments;
}
public static function detail($id, $currentSystemId = 0)
{
$id = (int) $id;
$profile = Model::customer($id, $currentSystemId);
if (!$profile) { return null; }
$orderData = self::orderData(
$id,
isset($profile['user']['email']) ? $profile['user']['email'] : '',
isset($profile['user']['phone']) ? $profile['user']['phone'] : ''
);
$profile['summary'] = $orderData['summary'];
$profile['orders'] = $orderData['orders'];
$profile['contacts'] = self::contacts(isset($profile['user']['email']) ? $profile['user']['email'] : '');
$profile['popup_leads'] = self::popupLeads(
isset($profile['user']['email']) ? $profile['user']['email'] : '',
isset($profile['user']['phone']) ? $profile['user']['phone'] : ''
);
$profile['quiz_leads'] = self::quizLeads(
isset($profile['user']['email']) ? $profile['user']['email'] : '',
isset($profile['user']['phone']) ? $profile['user']['phone'] : ''
);
$profile['identities'] = self::identities($id);
$profile['engagement'] = self::engagement($id);
$profile['notes'] = self::notes($id);
$profile['segments'] = self::segmentCodes(array_merge($profile['user'], $orderData['summary']));
return $profile;
}
public static function addNote($userId, $authorId, $text)
{
$userId = (int) $userId; $authorId = (int) $authorId;
$text = trim((string) $text);
if (!Model::customer($userId)) { throw new \InvalidArgumentException('Пользователь не найден'); }
if ($text === '') { throw new \InvalidArgumentException('Введите текст заметки'); }
if (mb_strlen($text, 'UTF-8') > 4000) { throw new \InvalidArgumentException('Заметка не должна превышать 4000 символов'); }
if (!DatabaseSchema::tableExists(self::notesTable())) { throw new \RuntimeException('Примените миграцию центра покупателей'); }
DB::Insert(self::notesTable(), array('user_id' => $userId, 'author_id' => $authorId, 'note' => $text, 'created_at' => time()));
return (int) DB::insertId();
}
public static function merge($targetId, $sourceId, $actorSystemId = 0)
{
$targetId = (int) $targetId; $sourceId = (int) $sourceId;
if ($targetId <= 0 || $sourceId <= 0 || $targetId === $sourceId) { throw new \InvalidArgumentException('Выберите два разных аккаунта'); }
$target = Model::customer($targetId, $actorSystemId); $source = Model::customer($sourceId, $actorSystemId);
if (!$target || !$source) { throw new \InvalidArgumentException('Один из аккаунтов не найден'); }
if (!empty($source['is_current']) || !empty($source['system'])) { throw new \InvalidArgumentException('Нельзя объединить аккаунт, связанный с доступом в панель'); }
self::assertIdentityMerge($targetId, $sourceId);
DB::startTransaction();
try {
$sourceUnique = array('email' => null);
if (DatabaseSchema::columnExists(PublicUserTables::table('users'), 'phone_normalized')) { $sourceUnique['phone_normalized'] = null; }
DB::Update(PublicUserTables::table('users'), $sourceUnique, 'Id=%i', $sourceId);
self::mergeCoreProfile($target['user'], $source['user']);
self::mergeProfileValues($targetId, $sourceId);
self::moveOptionalData($targetId, $sourceId);
$identities = PublicUserTables::table('user_identities');
if (DatabaseSchema::tableExists($identities)) { DB::Update($identities, array('user_id' => $targetId, 'updated_at' => time()), 'user_id=%i', $sourceId); }
if (DatabaseSchema::tableExists(self::notesTable())) { DB::Update(self::notesTable(), array('user_id' => $targetId), 'user_id=%i', $sourceId); }
self::addNote($targetId, $actorSystemId, 'Объединён аккаунт #' . $sourceId . '. Контакты источника: ' . trim((string) $source['user']['email'] . ' ' . (string) $source['user']['phone']));
DB::Update(PublicUserTables::table('users'), array('status' => '0', 'deleted' => '1', 'del_time' => time()), 'Id=%i', $sourceId);
DB::Delete(PublicUserTables::table('users_session'), 'user_id=%i', $sourceId);
DB::Delete(PublicUserTables::table('auth_tokens'), 'user_id=%i', $sourceId);
DB::commit();
} catch (\Throwable $e) { DB::rollback(); throw $e; }
return self::detail($targetId, $actorSystemId);
}
public static function duplicateGroups()
{
$table = PublicUserTables::table('users'); $groups = array();
$queries = array(
'email' => "SELECT LOWER(TRIM(email)) duplicate_value,GROUP_CONCAT(Id ORDER BY Id) ids,COUNT(*) amount FROM $table WHERE deleted!='1' AND email!='' GROUP BY LOWER(TRIM(email)) HAVING COUNT(*)>1 LIMIT 30",
);
if (DatabaseSchema::columnExists($table, 'phone_normalized')) {
$queries['phone'] = "SELECT phone_normalized duplicate_value,GROUP_CONCAT(Id ORDER BY Id) ids,COUNT(*) amount FROM $table WHERE deleted!='1' AND phone_normalized IS NOT NULL GROUP BY phone_normalized HAVING COUNT(*)>1 LIMIT 30";
}
foreach ($queries as $kind => $sql) {
foreach (DB::query($sql)->getAll() ?: array() as $row) {
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $row['ids'])))));
if (count($ids) > 1) { $groups[] = array('kind' => $kind, 'value' => (string) $row['duplicate_value'], 'ids' => $ids, 'target_id' => min($ids)); }
}
}
return $groups;
}
/**
* Заказы человека: свои по аккаунту плюс гостевые по контактам.
*
* Оформить заказ можно без входа (`order_user_id = 0`), и такие покупки
* раньше не попадали на карточку вовсе. Ищем их так же, как обращения и
* заявки — по email и телефону, — но помечаем `matched_by`, чтобы
* догадка не выдавалась за подтверждённую принадлежность аккаунту.
*/
protected static function orderData($userId, $email = '', $phone = '')
{
$table = BasketTables::table('module_basket_history');
$empty = array('summary' => array('orders_count' => 0, 'orders_total' => 0, 'last_order_at' => 0, 'guest_count' => 0), 'orders' => array());
if (!DatabaseSchema::tableExists($table)) { return $empty; }
$where = array('order_user_id=%i');
$args = array((int) $userId);
$email = trim((string) $email);
if ($email !== '') { $where[] = '(order_user_id=0 AND LOWER(order_email)=LOWER(%s))'; $args[] = $email; }
$digits = preg_replace('/\D+/', '', (string) $phone);
if (strlen($digits) >= 10) {
$where[] = "(order_user_id=0 AND RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(order_phone,' ',''),'(',''),')',''),'-',''),'+',''),10)=%s)";
$args[] = substr($digits, -10);
}
$condition = '(' . implode(' OR ', $where) . ')';
$summaryArgs = array_merge(array(
'SELECT COUNT(*) orders_count,COALESCE(SUM(order_total),0) orders_total,COALESCE(MAX(order_published),0) last_order_at,'
. ' COALESCE(SUM(order_user_id=0),0) guest_count FROM ' . $table . ' WHERE ' . $condition,
), $args);
$summary = call_user_func_array(array(DB::class, 'query'), $summaryArgs)->getAssoc() ?: array();
$listArgs = array_merge(array(
'SELECT id,order_id,order_total,order_status,order_pay,order_published,order_user_id FROM ' . $table
. ' WHERE ' . $condition . ' ORDER BY order_published DESC,id DESC LIMIT 20',
), $args);
$rows = call_user_func_array(array(DB::class, 'query'), $listArgs)->getAll() ?: array();
foreach ($rows as &$row) {
$row['id'] = (int) $row['id'];
$row['order_total'] = (float) $row['order_total'];
$row['order_published'] = (int) $row['order_published'];
$row['matched_by'] = (int) $row['order_user_id'] > 0 ? 'account' : 'contact';
}
unset($row);
return array('summary' => array(
'orders_count' => isset($summary['orders_count']) ? (int) $summary['orders_count'] : 0,
'orders_total' => isset($summary['orders_total']) ? (float) $summary['orders_total'] : 0,
'last_order_at' => isset($summary['last_order_at']) ? (int) $summary['last_order_at'] : 0,
'guest_count' => isset($summary['guest_count']) ? (int) $summary['guest_count'] : 0,
), 'orders' => $rows);
}
protected static function contacts($email)
{
$table = ContactsTables::table('module_contacts_history'); $email = trim((string) $email);
if ($email === '' || !DatabaseSchema::tableExists($table)) { return array(); }
return DB::query('SELECT id,form_id,subject,status,date FROM ' . $table . ' WHERE LOWER(email)=LOWER(%s) ORDER BY date DESC,id DESC LIMIT 20', $email)->getAll() ?: array();
}
/**
* Заявки из поп-апов сопоставляются по email или телефону.
* Модуль устанавливается отдельно, поэтому отсутствие таблицы —
* обычное состояние, а не ошибка.
*/
protected static function popupLeads($email, $phone)
{
if (!class_exists('App\\Modules\\Popups\\Repository')) { return array(); }
try {
if (!DatabaseSchema::tableExists(\App\Modules\Popups\Tables::table('leads'))) { return array(); }
return \App\Modules\Popups\Repository::leadsForContact($email, $phone, 20);
} catch (\Throwable $e) {
return array();
}
}
/** Заявки из пошагового подбора — тот же принцип, что и у поп-апов. */
protected static function quizLeads($email, $phone)
{
if (!class_exists('App\\Modules\\Quiz\\Repository')) { return array(); }
try { return \App\Modules\Quiz\Repository::leadsForContact($email, $phone, 20); }
catch (\Throwable $e) { return array(); }
}
protected static function identities($userId)
{
$table = PublicUserTables::table('user_identities');
return DatabaseSchema::tableExists($table) ? (DB::query('SELECT provider,email,created_at,last_login_at FROM ' . $table . ' WHERE user_id=%i ORDER BY provider', (int) $userId)->getAll() ?: array()) : array();
}
protected static function engagement($userId)
{
$result = array('favorites' => array(), 'viewed' => array());
$favorites = BasketTables::table('module_basket_favorites');
if (DatabaseSchema::tableExists($favorites)) { $result['favorites'] = array_map('intval', array_column(DB::query('SELECT fav_document_id FROM ' . $favorites . ' WHERE fav_user_id=%i ORDER BY fav_created_at DESC LIMIT 30', (int) $userId)->getAll() ?: array(), 'fav_document_id')); }
$viewed = BasketTables::table('module_basket_viewed');
if (DatabaseSchema::tableExists($viewed)) { $result['viewed'] = array_map('intval', array_column(DB::query('SELECT viewed_document_id FROM ' . $viewed . ' WHERE viewed_user_id=%i ORDER BY viewed_at DESC LIMIT 30', (int) $userId)->getAll() ?: array(), 'viewed_document_id')); }
return $result;
}
protected static function notes($userId)
{
if (!DatabaseSchema::tableExists(self::notesTable())) { return array(); }
return DB::query('SELECT n.*,u.name author_name FROM ' . self::notesTable() . ' n LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id=n.author_id WHERE n.user_id=%i ORDER BY n.created_at DESC,n.id DESC LIMIT 100', (int) $userId)->getAll() ?: array();
}
protected static function segmentCodes(array $row)
{
$codes = array(); $now = time();
if (!empty($row['reg_time']) && (int) $row['reg_time'] >= $now - 30 * 86400) { $codes[] = 'new'; }
if (!empty($row['orders_count'])) { $codes[] = 'buyers'; }
if (isset($row['orders_count']) && (int) $row['orders_count'] >= 2) { $codes[] = 'repeat'; }
if (isset($row['orders_total']) && (float) $row['orders_total'] >= 100000) { $codes[] = 'vip'; }
if (empty($row['last_visit']) || (int) $row['last_visit'] < $now - 180 * 86400) { $codes[] = 'inactive'; }
if (empty($row['orders_count'])) { $codes[] = 'without_orders'; }
return $codes;
}
protected static function assertIdentityMerge($targetId, $sourceId)
{
$table = PublicUserTables::table('user_identities');
if (!DatabaseSchema::tableExists($table)) { return; }
$conflict = DB::query('SELECT source.provider FROM ' . $table . ' source INNER JOIN ' . $table . ' target ON target.provider=source.provider AND target.user_id=%i WHERE source.user_id=%i LIMIT 1', (int) $targetId, (int) $sourceId)->getValue();
if ($conflict) { throw new \InvalidArgumentException('У обоих аккаунтов подключён один способ входа: ' . $conflict); }
}
protected static function mergeProfileValues($targetId, $sourceId)
{
$table = PublicUserTables::table('user_profile_values');
if (!DatabaseSchema::tableExists($table)) { return; }
foreach (DB::query('SELECT field_id,value FROM ' . $table . ' WHERE user_id=%i', (int) $sourceId)->getAll() ?: array() as $row) {
$current = DB::query('SELECT value FROM ' . $table . ' WHERE user_id=%i AND field_id=%i', (int) $targetId, (int) $row['field_id'])->getValue();
if ($current === null) { DB::Insert($table, array('user_id' => $targetId, 'field_id' => (int) $row['field_id'], 'value' => (string) $row['value'], 'updated_at' => time())); }
elseif (trim((string) $current) === '' && trim((string) $row['value']) !== '') { DB::Update($table, array('value' => (string) $row['value'], 'updated_at' => time()), 'user_id=%i AND field_id=%i', $targetId, (int) $row['field_id']); }
}
DB::Delete($table, 'user_id=%i', $sourceId);
}
protected static function mergeCoreProfile(array $target, array $source)
{
$fields = array('email', 'phone', 'phone_normalized', 'firstname', 'lastname', 'street', 'street_nr', 'zipcode', 'city', 'telefax', 'description', 'company', 'birthday', 'country');
$values = array();
foreach ($fields as $field) {
if (trim(isset($target[$field]) ? (string) $target[$field] : '') === '' && trim(isset($source[$field]) ? (string) $source[$field] : '') !== '') {
$values[$field] = $source[$field];
}
}
$targetEmail = strtolower(trim(isset($target['email']) ? (string) $target['email'] : ''));
$sourceEmail = strtolower(trim(isset($source['email']) ? (string) $source['email'] : ''));
if ($targetEmail === '' || ($targetEmail !== '' && $targetEmail === $sourceEmail)) {
$values['email_verified_at'] = max(isset($target['email_verified_at']) ? (int) $target['email_verified_at'] : 0, isset($source['email_verified_at']) ? (int) $source['email_verified_at'] : 0);
}
$targetPhone = trim(isset($target['phone_normalized']) ? (string) $target['phone_normalized'] : '');
$sourcePhone = trim(isset($source['phone_normalized']) ? (string) $source['phone_normalized'] : '');
if ($targetPhone === '' || ($targetPhone !== '' && $targetPhone === $sourcePhone)) {
$values['phone_verified_at'] = max(isset($target['phone_verified_at']) ? (int) $target['phone_verified_at'] : 0, isset($source['phone_verified_at']) ? (int) $source['phone_verified_at'] : 0);
}
if ($values) { DB::Update(PublicUserTables::table('users'), $values, 'Id=%i', (int) $target['id']); }
}
protected static function moveOptionalData($targetId, $sourceId)
{
$orders = BasketTables::table('module_basket_history');
if (DatabaseSchema::tableExists($orders)) { DB::Update($orders, array('order_user_id' => $targetId), 'order_user_id=%i', $sourceId); }
$favorites = BasketTables::table('module_basket_favorites');
if (DatabaseSchema::tableExists($favorites)) {
DB::query('INSERT IGNORE INTO ' . $favorites . ' (fav_user_id,fav_document_id,fav_created_at) SELECT %i,fav_document_id,fav_created_at FROM ' . $favorites . ' WHERE fav_user_id=%i', $targetId, $sourceId);
DB::Delete($favorites, 'fav_user_id=%i', $sourceId);
}
$viewed = BasketTables::table('module_basket_viewed');
if (DatabaseSchema::tableExists($viewed)) {
DB::query('INSERT INTO ' . $viewed . ' (viewed_user_id,viewed_document_id,viewed_at) SELECT %i,source.viewed_document_id,source.viewed_at FROM ' . $viewed . ' source WHERE source.viewed_user_id=%i ON DUPLICATE KEY UPDATE viewed_at=GREATEST(' . $viewed . '.viewed_at,VALUES(viewed_at))', $targetId, $sourceId);
DB::Delete($viewed, 'viewed_user_id=%i', $sourceId);
}
}
protected static function notesTable()
{
return SystemTables::prefix() . '_customer_notes';
}
}
@@ -0,0 +1,42 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Customers/GlobalSearchProvider.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Customers;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Content\PublicUserTables;
use DB;
class GlobalSearchProvider
{
public static function search($query, $limit = 8)
{
$query = trim((string) $query); if ($query === '') { return array(); }
$sql = 'SELECT Id,email,firstname,lastname,user_name,phone,company,status FROM ' . PublicUserTables::table('users')
. ' WHERE deleted!=%s AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR user_name LIKE %ss OR phone LIKE %ss OR company LIKE %ss';
$args = array('1', $query, $query, $query, $query, $query, $query);
if (ctype_digit($query)) { $sql .= ' OR Id=%i'; $args[] = (int) $query; }
$sql .= ') ORDER BY status DESC,Id DESC LIMIT ' . max(1, min(12, (int) $limit));
$rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$title = trim($row['firstname'] . ' ' . $row['lastname']); if ($title === '') { $title = $row['user_name'] ?: ($row['email'] ?: $row['phone']); }
$subtitle = '#' . (int) $row['Id']; if ($row['email']) { $subtitle .= ' · ' . $row['email']; } if ((string) $row['status'] !== '1') { $subtitle .= ' · отключён'; }
$out[] = array('type' => 'customer', 'group' => 'Пользователи сайта', 'title' => $title, 'subtitle' => $subtitle, 'url' => '/system/customers?tab=customers&q=' . rawurlencode((string) ($row['email'] ?: $row['user_name'])), 'icon' => 'ti ti-user-heart', 'score' => 50);
}
return $out;
}
}
+108 -11
View File
@@ -20,6 +20,7 @@
use App\Adminx\Support\Roles;
use App\Common\Auth\IdentityLinker;
use App\Common\ModuleManager;
use App\Common\SystemTables;
use App\Content\PublicUserTables;
use App\Content\ContentTables;
use App\Common\PublicAuthSettings;
@@ -38,20 +39,39 @@
if($q!==''){$sql.=' AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR phone LIKE %ss OR company LIKE %ss)';for($i=0;$i<5;$i++){$args[]=$q;}}$sql.=' ORDER BY Id DESC LIMIT 500';return call_user_func_array(array('DB','query'),array_merge(array($sql),$args))->getAll()?:array();
}
public static function exportChunk($q, $beforeId, $limit = 500)
{
$sql = 'SELECT Id AS id,email,firstname,lastname,user_name,phone,company,status,reg_time,last_visit FROM ' . self::table('users') . ' WHERE deleted!=%s';
$args = array('1'); $q = trim((string) $q);
if ($q !== '') { $sql .= ' AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR phone LIKE %ss OR company LIKE %ss)'; for ($i = 0; $i < 5; $i++) { $args[] = $q; } }
if ((int) $beforeId > 0) { $sql .= ' AND Id<%i'; $args[] = (int) $beforeId; }
$sql .= ' ORDER BY Id DESC LIMIT ' . max(1, min(1000, (int) $limit));
return call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array();
}
public static function stats()
{
$table=self::table('users');return array('total'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s','1')->getValue(),'active'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND status=%s','1','1')->getValue(),'verified'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND (email_verified_at>0 OR phone_verified_at>0)','1')->getValue(),'fields'=>count(self::fields()));
}
public static function toggle($id)
public static function toggle($id, $currentSystemId = 0)
{
$customer = self::customer($id, $currentSystemId);
if (!$customer) {
throw new \InvalidArgumentException('Пользователь не найден');
}
if (!empty($customer['is_current'])) {
throw new \InvalidArgumentException('Нельзя отключить собственную учётную запись');
}
DB::query('UPDATE '.self::table('users')." SET status=IF(status='1','0','1') WHERE Id=%i AND deleted!=%s",(int)$id,'1');
$active=(string)DB::query('SELECT status FROM '.self::table('users').' WHERE Id=%i',(int)$id)->getValue()==='1';
if(!$active){self::invalidateCustomerSessions((int)$id);}
return $active;
}
public static function customer($id)
public static function customer($id, $currentSystemId = 0)
{
$row=DB::query('SELECT Id AS id,email,email_verified_at,firstname,lastname,user_name,phone,phone_normalized,phone_verified_at,company,city,street,street_nr,zipcode,birthday,description,user_group,status,reg_time,last_visit FROM '.self::table('users').' WHERE Id=%i AND deleted!=%s LIMIT 1',(int)$id,'1')->getAssoc();
if(!$row){return null;}
@@ -63,14 +83,43 @@
$system=IdentityLinker::systemForPublic((int)$id,(string)$row['email']);
return array('user'=>$row,'extra'=>$extra,'system'=>$system?array(
'id'=>(int)$system['id'],'role'=>(string)$system['role'],'is_active'=>(int)$system['is_active'],
):null);
):null,'is_current'=>$system&&(int)$system['id']===(int)$currentSystemId);
}
public static function publicIdForSystem($systemId)
{
$system = DB::query(
'SELECT * FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1',
(int) $systemId
)->getAssoc();
if (!$system) {
return 0;
}
$public = IdentityLinker::publicForSystem((array) $system);
return $public ? (int) $public['Id'] : 0;
}
public static function updateCustomer($id,array $input,$currentSystemId=0)
{
$id=(int)$id;
$current=self::customer($id);
$current=self::customer($id,$currentSystemId);
if(!$current){throw new \InvalidArgumentException('Пользователь не найден');}
if(!empty($current['is_current'])){
$currentSystem=$current['system'];
if(isset($input['user_group'])&&(int)$input['user_group']!==(int)$current['user']['user_group']){
throw new \InvalidArgumentException('Нельзя изменить собственную публичную группу');
}
if(isset($input['admin_role'])&&(string)$input['admin_role']!==(string)$currentSystem['role']){
throw new \InvalidArgumentException('Нельзя изменить собственную роль в панели управления');
}
$input['user_group']=(string)$current['user']['user_group'];
$input['status']=(string)$current['user']['status']==='1'?'1':'';
$input['admin_access']=!empty($currentSystem['is_active'])?'1':'';
$input['admin_role']=(string)$currentSystem['role'];
}
$email=mb_strtolower(trim((string)(isset($input['email'])?$input['email']:'')));
$phoneInput=trim((string)(isset($input['phone'])?$input['phone']:''));
@@ -132,6 +181,40 @@
return self::customer($id);
}
public static function deleteCustomer($id, $currentSystemId = 0)
{
$id = (int) $id;
$current = self::customer($id, $currentSystemId);
if (!$current) {
throw new \InvalidArgumentException('Пользователь не найден');
}
if (!empty($current['is_current'])) {
throw new \InvalidArgumentException('Нельзя удалить собственную учётную запись');
}
if (!empty($current['system']['is_active'])) {
throw new \InvalidArgumentException('Сначала отключите пользователю доступ к панели управления');
}
DB::startTransaction();
try {
DB::Update(self::table('users'), array(
'status' => '0',
'deleted' => '1',
'del_time' => time(),
), 'Id = %i', $id);
self::invalidateCustomerSessions($id);
DB::Delete(self::table('user_identities'), 'user_id = %i', $id);
DB::commit();
} catch (\Throwable $e) {
DB::rollback();
throw $e;
}
return true;
}
public static function fields()
{
$rows = DB::query('SELECT * FROM '.self::table('user_profile_fields').' ORDER BY position,id')->getAll() ?: array();
@@ -191,7 +274,7 @@
return $settings;
}
public static function oauthModules()
public static function authMethods()
{
$definitions = array(
array(
@@ -217,24 +300,33 @@
$items = array();
foreach ($definitions as $definition) {
$module = ModuleManager::get($definition['module']);
if (!$module || empty($module['installed'])) { continue; }
$available = $module !== null;
$installed = $available && !empty($module['installed']);
$isPhone = isset($definition['kind']) && $definition['kind'] === 'phone';
if ($isPhone) {
if ($installed && $isPhone) {
$phoneProvider = PhoneProviderRegistry::get($definition['provider']);
$isRegistered = $phoneProvider !== null;
$config = class_exists('\\App\\Modules\\SmscAuth\\SecretStore')
? \App\Modules\SmscAuth\SecretStore::config()
: array();
$configured = $isRegistered && $phoneProvider->configured();
} else {
} elseif ($installed) {
$isRegistered = in_array($definition['provider'], $registered, true);
$config = $isRegistered ? ProviderRegistry::config($definition['provider']) : array();
$configured = $isRegistered && ProviderRegistry::configured($definition['provider'], $config);
} else {
$config = array();
$configured = false;
}
$active = !empty($module['enabled']) && $configured && !empty($config['enabled']);
if (empty($module['enabled'])) {
$moduleEnabled = $installed && !empty($module['enabled']);
$active = $moduleEnabled && $configured && !empty($config['enabled']);
if (!$available) {
$state = array('label' => 'Пакет отсутствует', 'badge' => 'badge-red');
} elseif (!$installed) {
$state = array('label' => 'Не установлен', 'badge' => 'badge-gray');
} elseif (!$moduleEnabled) {
$state = array('label' => 'Модуль выключен', 'badge' => 'badge-gray');
} elseif (!$configured) {
$state = array('label' => 'Нужны ключи', 'badge' => 'badge-amber');
@@ -245,7 +337,9 @@
}
$items[] = array_merge($definition, array(
'module_enabled' => !empty($module['enabled']),
'available' => $available,
'installed' => $installed,
'module_enabled' => $moduleEnabled,
'configured' => $configured,
'active' => $active,
'allow_registration' => !empty($config['allow_registration']),
@@ -317,6 +411,9 @@
'profile' => array('label' => 'Профиль', 'description' => 'Контактные и дополнительные поля пользователя.', 'icon' => 'ti-user-circle', 'tile' => 'violet'),
'password' => array('label' => 'Смена пароля', 'description' => 'Форма для авторизованного пользователя.', 'icon' => 'ti-lock-cog', 'tile' => 'cyan'),
'message' => array('label' => 'Системное сообщение', 'description' => 'Результат регистрации, подтверждения и восстановления.', 'icon' => 'ti-message-circle-check', 'tile' => 'green'),
'phone' => array('label' => 'Вход по телефону', 'description' => 'Запрос и проверка одноразового SMS-кода.', 'icon' => 'ti-device-mobile-message', 'tile' => 'cyan'),
'oauth' => array('label' => 'Вход через сервисы', 'description' => 'Кнопки входа через подключённые OAuth-модули.', 'icon' => 'ti-brand-openid', 'tile' => 'blue'),
'oauth_connections' => array('label' => 'Привязанные сервисы', 'description' => 'Управление внешними способами входа в профиле.', 'icon' => 'ti-plug-connected', 'tile' => 'violet'),
);
}
+330 -5
View File
@@ -33,6 +33,310 @@
.customers-table td:last-child {
text-align: right;
}
.customers-center-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.customers-segments {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 14px 0;
}
.customers-segments a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 7px 11px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--background-surface);
color: var(--text-secondary);
font-size: 12px;
text-decoration: none;
}
.customers-segments a:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.customers-segments a.is-active {
border-color: var(--color-primary);
background: var(--blue-50);
color: var(--color-primary);
font-weight: 700;
}
.customers-duplicates {
display: block;
margin: 0 0 14px;
padding: 0;
overflow: hidden;
}
.customers-duplicates > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 15px;
cursor: pointer;
list-style: none;
}
.customers-duplicates > summary::-webkit-details-marker {
display: none;
}
.customers-duplicates > summary > span:first-child {
display: flex;
align-items: center;
gap: 8px;
}
.customers-duplicates > div {
display: grid;
gap: 8px;
padding: 0 14px 14px;
}
.customers-duplicates article {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: var(--radius-sm);
background: var(--background-surface);
}
.customers-duplicates article > span:first-child {
flex: 1 1 auto;
min-width: 0;
}
.customers-duplicates article b,
.customers-duplicates article small {
display: block;
}
.customers-duplicates article small {
color: var(--text-secondary);
}
.customers-center-list {
padding: 0;
}
.customers-center-table th:nth-child(1) {
width: auto;
}
.customers-center-table th:nth-child(2) {
width: 150px;
}
.customers-center-table th:nth-child(3) {
width: 180px;
}
.customers-center-table th:nth-child(4) {
width: 260px;
}
.customers-center-table td > small,
.customers-center-table td > b {
display: block;
}
.customers-center-table td > small {
margin-top: 3px;
color: var(--text-secondary);
font-size: 11px;
}
.customers-center-table tbody tr[data-customer-center-open] {
cursor: pointer;
}
.customers-center-person {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.customers-center-person > span,
.customers-center-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: var(--radius-sm);
background: var(--blue-100);
color: var(--blue-700);
font-weight: 800;
}
.customers-center-person > span {
width: 38px;
height: 38px;
font-size: 12px;
}
.customers-center-person > div {
min-width: 0;
}
.customers-center-person b,
.customers-center-person small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.customers-center-person small {
margin-top: 3px;
color: var(--text-secondary);
font-size: 11px;
}
.customers-center-segment-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.customer-center-drawer {
width: min(66.666vw, 1280px);
max-width: none;
}
.customer-center-drawer .drawer-footer {
justify-content: flex-end;
}
.customers-center-detail {
display: grid;
align-content: start;
gap: 14px;
background: var(--background-inset);
}
.customers-center-profile {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
border-radius: var(--radius-sm);
background: var(--background-surface);
}
.customers-center-avatar {
width: 58px;
height: 58px;
font-size: 18px;
}
.customers-center-profile > div:nth-child(2) {
flex: 1 1 auto;
min-width: 0;
}
.customers-center-profile h2,
.customers-center-profile p {
margin: 0;
}
.customers-center-profile h2 {
margin: 3px 0;
font-size: 20px;
}
.customers-center-profile p {
color: var(--text-secondary);
}
.customers-center-profile .customers-center-segment-list {
margin-top: 8px;
}
.customers-center-detail-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.customers-center-detail-kpis > span {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
padding: 11px 12px;
border-radius: var(--radius-sm);
background: var(--background-surface);
}
.customers-center-detail-kpis i {
display: grid;
grid-row: 1/3;
place-items: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--blue-100);
color: var(--blue-600);
font-size: 17px;
}
.customers-center-detail-kpis b,
.customers-center-detail-kpis small {
display: block;
}
.customers-center-detail-kpis small {
color: var(--text-secondary);
font-size: 10px;
}
.customers-center-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
align-items: start;
}
.customers-center-detail-grid > .card {
min-width: 0;
padding: 0;
}
.customers-center-order-list,
.customers-center-contact-list,
.customers-center-identities,
.customers-center-notes {
display: grid;
gap: 7px;
padding: 12px;
}
.customers-center-order-list a,
.customers-center-contact-list article,
.customers-center-identities article {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: var(--background-inset);
color: var(--text-primary);
text-decoration: none;
}
.customers-center-order-list a:hover {
background: var(--background-hover);
}
.customers-center-order-list b,
.customers-center-order-list small,
.customers-center-identities b,
.customers-center-identities small,
.customers-center-contact-list b,
.customers-center-contact-list small {
display: block;
}
.customers-center-order-list small,
.customers-center-identities small,
.customers-center-contact-list small {
margin-top: 2px;
color: var(--text-secondary);
font-size: 10.5px;
}
.customers-center-identities article {
justify-content: flex-start;
}
.customers-center-identities article > i {
color: var(--color-primary);
font-size: 19px;
}
.customers-center-notes article {
padding: 10px;
border-radius: var(--radius-sm);
background: var(--background-inset);
}
.customers-center-notes p {
margin: 0;
white-space: pre-wrap;
}
.customers-center-notes small {
display: block;
margin-top: 6px;
color: var(--text-secondary);
font-size: 10.5px;
}
.customers-center-note-form {
display: grid;
gap: 9px;
padding: 12px;
border-top: 1px solid var(--border-default);
}
.customers-center-note-form .btn {
justify-self: end;
}
.customer-editor-drawer {
width: min(66.666vw, 1240px);
max-width: none;
@@ -97,6 +401,16 @@
.customers-editor-access .customers-switch-card:last-child {
grid-column: 1 / -1;
}
.customers-editor-input {
width: 100%;
}
.customers-editor-input .input {
width: 100%;
}
.customers-switch-card.is-locked {
opacity: 0.68;
cursor: not-allowed;
}
.customers-editor-checkbox {
display: flex;
align-items: center;
@@ -797,7 +1111,8 @@
@media (max-width: 980px) {
.customers-field-builder,
.customers-pages-grid,
.customers-social-grid {
.customers-social-grid,
.customers-center-detail-grid {
grid-template-columns: 1fr;
}
.customers-profile-preview {
@@ -806,6 +1121,10 @@
.customers-auth-settings-grid {
grid-template-columns: 1fr 1fr;
}
.customers-center-kpis,
.customers-center-detail-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.customers-auth-template-layout {
@@ -827,11 +1146,14 @@
}
.customer-editor-drawer,
.customer-field-drawer,
.customer-auth-form-drawer {
.customer-auth-form-drawer,
.customer-center-drawer {
width: 100%;
max-width: 100%;
}
.customers-editor-meta {
.customers-editor-meta,
.customers-center-kpis,
.customers-center-detail-kpis {
grid-template-columns: 1fr;
}
.customers-switch-grid,
@@ -848,11 +1170,14 @@
.customers-social-callback {
grid-column: auto;
}
.customers-social-status {
.customers-social-status,
.customers-center-profile,
.customers-duplicates article {
align-items: stretch;
flex-direction: column;
}
.customers-social-status > .btn {
.customers-social-status > .btn,
.customers-center-profile > .btn {
align-self: flex-end;
}
.customers-page-fields .field:nth-child(3),
+124 -1
View File
@@ -21,6 +21,7 @@
if (event.target.matches('[data-customer-field] [name="type"]')) { self.updateOptionsVisibility(); }
if (event.target.matches('[data-auth-show]')) { self.syncAuthFieldRows(); }
if (event.target.matches('[data-registration-mode]')) { self.syncAuthMode(); }
if (event.target.matches('[data-registration-gate]')) { self.syncRegistrationGate(); }
if (event.target.matches('[data-customer-admin-access]')) { self.syncAdminAccess(); }
});
@@ -29,8 +30,14 @@
});
document.addEventListener('click', function (event) {
var centerOpen = event.target.closest('[data-customer-center-open]');
if (centerOpen) { event.preventDefault(); self.openCustomerCenter(centerOpen.getAttribute('data-url')); return; }
var merge = event.target.closest('[data-customer-merge]');
if (merge) { event.preventDefault(); self.mergeCustomers(merge); return; }
var customerEdit = event.target.closest('[data-customer-edit]');
if (customerEdit) { event.preventDefault(); self.openCustomer(customerEdit); return; }
var customerDelete = event.target.closest('[data-customer-delete]');
if (customerDelete) { event.preventDefault(); self.deleteCustomer(customerDelete); return; }
if (event.target.closest('[data-field-new]')) { self.fieldForm(null); }
var edit = event.target.closest('[data-field-edit]');
if (edit) { self.fieldForm(JSON.parse(edit.closest('[data-field]').getAttribute('data-field'))); }
@@ -49,6 +56,13 @@
if (checkoutDefault) { self.setCheckoutTemplate(checkoutDefault.getAttribute('data-template') || ''); }
});
document.addEventListener('submit', function (event) {
var note = event.target.closest('[data-customer-note]');
if (!note) { return; }
event.preventDefault();
self.request(note.action, new FormData(note)).then(function () { self.openCustomerCenter(note.getAttribute('data-refresh-url'), true); });
});
var form = document.querySelector('[data-customer-field]');
if (form) {
form.addEventListener('submit', function (event) {
@@ -93,6 +107,7 @@
});
}
this.syncAuthFieldRows();
this.syncRegistrationGate();
this.syncAuthMode();
this.initSortable();
},
@@ -111,6 +126,34 @@
});
},
openCustomerCenter: function (url, keepOpen) {
var detail = document.querySelector('[data-customer-center-detail]');
if (!detail || !url) { return; }
detail.innerHTML = '<div class="skeleton" style="height:160px"></div>';
if (!keepOpen && Adminx.Drawer) { Adminx.Drawer.open('customerCenterDrawer'); }
Adminx.Loader.show();
Adminx.Ajax.request(url, { method: 'GET' }).then(function (payload) {
Adminx.Loader.hide();
if (!payload.ok || !payload.data.success) { throw new Error(payload.data.message || Adminx.tr('Не удалось загрузить карточку')); }
detail.innerHTML = payload.data.html && payload.data.html.detail ? payload.data.html.detail : '<div class="empty-state">' + Adminx.tr('Нет данных') + '</div>';
}).catch(function (error) { Adminx.Loader.hide(); detail.innerHTML = '<div class="empty-state">' + error.message + '</div>'; Adminx.Toast.show(error.message, 'error'); });
},
mergeCustomers: function (button) {
var self = this;
var target = Number(button.getAttribute('data-target-id')) || 0;
var source = Number(button.getAttribute('data-source-id')) || 0;
Adminx.Confirm.open({
kind: 'warning', title: Adminx.tr('Объединить аккаунты?'),
message: Adminx.tr('Данные исходного аккаунта будут перенесены в основной. Исходный аккаунт будет отключён и удалён.') + ' #' + source + ' → #' + target,
confirmLabel: Adminx.tr('Объединить'),
onConfirm: function () {
var body = new FormData(); body.set('target_id', target); body.set('source_id', source);
return self.request(Adminx.base() + '/system/customers/center/merge', body).then(function () { window.location.reload(); });
}
});
},
openCustomer: function (button) {
var form = document.querySelector('[data-customer-editor]');
var self = this;
@@ -135,6 +178,7 @@
if (!form) { return; }
form.reset();
form.dataset.id = user.id || '0';
form.dataset.current = data.is_current ? '1' : '0';
['firstname', 'lastname', 'email', 'user_name', 'phone', 'company', 'birthday', 'description', 'city', 'street', 'street_nr', 'zipcode', 'user_group'].forEach(function (name) {
if (form.elements[name]) { form.elements[name].value = user[name] == null ? '' : user[name]; }
});
@@ -145,6 +189,7 @@
form.elements.phone_verified.checked = Number(user.phone_verified_at) > 0;
form.elements.admin_access.checked = !!(data.system && Number(data.system.is_active) === 1);
form.elements.admin_role.value = data.system && data.system.role ? data.system.role : 'manager';
this.syncCurrentAccountProtection();
this.syncAdminAccess();
Object.keys(extra).forEach(function (id) {
var input = form.elements['extra[' + id + ']'];
@@ -179,9 +224,51 @@
var form = document.querySelector('[data-customer-editor]');
var field = form ? form.querySelector('[data-customer-admin-role]') : null;
var toggle = form && form.elements.admin_access ? form.elements.admin_access : null;
var isCurrent = form && form.dataset.current === '1';
var canManage = form && form.dataset.canManageAdminAccess === '1';
if (!field || !toggle) { return; }
field.hidden = !toggle.checked;
if (form.elements.admin_role) { form.elements.admin_role.disabled = !toggle.checked; }
if (form.elements.admin_role) { form.elements.admin_role.disabled = !toggle.checked || !canManage || isCurrent; }
},
syncCurrentAccountProtection: function () {
var form = document.querySelector('[data-customer-editor]');
var isCurrent = form && form.dataset.current === '1';
var canManage = form && form.dataset.canManageAdminAccess === '1';
var accountHint = form ? form.querySelector('[data-customer-account-hint]') : null;
var adminHint = form ? form.querySelector('[data-customer-admin-hint]') : null;
var groupHint = form ? form.querySelector('[data-customer-group-hint]') : null;
var roleHint = form ? form.querySelector('[data-customer-admin-role-hint]') : null;
var accountCard = form ? form.querySelector('[data-customer-account-card]') : null;
var adminCard = form ? form.querySelector('[data-customer-admin-card]') : null;
if (!form) { return; }
form.elements.user_group.disabled = isCurrent;
form.elements.status.disabled = isCurrent;
form.elements.admin_access.disabled = isCurrent || !canManage;
if (accountCard) { accountCard.classList.toggle('is-locked', isCurrent); }
if (adminCard) { adminCard.classList.toggle('is-locked', isCurrent || !canManage); }
if (accountHint) {
accountHint.textContent = isCurrent
? 'Собственную учётную запись нельзя отключить.'
: 'Может входить на сайт и в личный кабинет.';
}
if (adminHint) {
adminHint.textContent = isCurrent
? 'Собственный доступ к панели нельзя отключить.'
: (canManage
? 'Та же учётная запись сможет работать в административном интерфейсе.'
: 'Изменение требует права управления системными пользователями.');
}
if (groupHint) {
groupHint.textContent = isCurrent
? 'Собственную публичную группу изменяйте через другого администратора.'
: 'Определяет права пользователя на публичной части сайта.';
}
if (roleHint) {
roleHint.textContent = isCurrent
? 'Собственную роль изменяйте через другого администратора.'
: 'Права роли настраиваются в разделе «Роли и права».';
}
},
formatTimestamp: function (value) {
@@ -206,6 +293,22 @@
});
},
deleteCustomer: function (button) {
var self = this;
Adminx.Confirm.open({
kind: 'error',
title: 'Удалить пользователя сайта?',
message: 'Аккаунт будет отключён, а его публичные сессии завершены. История заказов и связанные записи сохранятся.',
confirmLabel: 'Удалить',
confirmClass: 'btn-danger',
onConfirm: function () {
self.request(button.getAttribute('data-url'), new FormData()).then(function () {
window.location.reload();
});
}
});
},
toggleField: function (input) {
var self = this;
var item = input.closest('[data-field-id]');
@@ -292,6 +395,26 @@
emailOnly.hidden = mode.value !== 'email';
},
syncRegistrationGate: function () {
var gate = document.querySelector('[data-registration-gate]');
var hasEmail = gate && gate.value !== 'phone';
var emailAndPhone = gate && gate.value === 'email_phone';
var description = document.querySelector('[data-auth-email-field-description]');
var visibility = document.querySelector('[data-auth-email-visibility]');
var required = document.querySelector('[data-auth-email-required]');
if (!gate) { return; }
document.querySelectorAll('[data-auth-email-registration], [data-auth-email-form-fields], [data-auth-email-settings]').forEach(function (section) {
section.hidden = !hasEmail;
});
if (description) { description.textContent = emailAndPhone ? 'Запрашивается только при выборе регистрации по email' : 'Логин и канал подтверждения регистрации по email'; }
if (visibility) { visibility.textContent = emailAndPhone ? 'В email-форме' : 'Показывается'; }
if (required) {
required.textContent = emailAndPhone ? 'По выбору способа' : 'Обязательно';
required.classList.toggle('badge-blue', !emailAndPhone);
required.classList.toggle('badge-gray', emailAndPhone);
}
},
setCheckoutTemplate: function (value) {
var form = document.querySelector('[data-customer-auth]');
var textarea = form ? form.elements.checkout_access_template : null;
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="customers_center_title">Customer center</phrase>
<phrase data="customers_center_eyebrow">Customer operations</phrase>
<phrase data="customers_center_description">Account, purchases, inquiries, and customer interests are collected in one card.</phrase>
<phrase data="customers_center_accounts">Accounts</phrase>
<phrase data="customers_center_buyers">Customers</phrase>
<phrase data="customers_center_repeat">Repeat customers</phrase>
<phrase data="customers_center_duplicates">Duplicate groups</phrase>
<phrase data="customers_center_duplicate_warning">Possible duplicates found</phrase>
<phrase data="customers_center_duplicate_email">Same email</phrase>
<phrase data="customers_center_duplicate_phone">Same phone</phrase>
<phrase data="customers_center_open">Open customer card</phrase>
<phrase data="customers_center_card">Customer card</phrase>
<phrase data="customers_center_card_hint">Profile and interaction history</phrase>
<phrase data="customers_center_orders">Orders</phrase>
<phrase data="customers_center_login_methods">Login methods</phrase>
<phrase data="customers_center_contacts">Inquiries</phrase>
<phrase data="customers_center_notes">Manager notes</phrase>
<phrase data="customers_center_note_hint">Internal information that is not visible to the customer</phrase>
<phrase data="customers_center_note_new">New note</phrase>
<phrase data="customers_center_note_added">Note added</phrase>
<phrase data="customers_center_merged">Accounts merged</phrase>
<phrase data="customers_center_segment_all">All</phrase>
<phrase data="customers_center_segment_new">New</phrase>
<phrase data="customers_center_segment_buyers">With orders</phrase>
<phrase data="customers_center_segment_repeat">Repeat</phrase>
<phrase data="customers_center_segment_vip">Over ₽100,000</phrase>
<phrase data="customers_center_segment_inactive">Inactive</phrase>
<phrase data="customers_center_segment_empty">Without orders</phrase>
</language>
@@ -2,30 +2,12 @@
<language>
<phrase data="auto_32b373146ceb643e">Failed to load profile</phrase>
<phrase data="auto_3ad78d4634845d06">New field</phrase>
<phrase data="auto_453d46b73a83a1f7">The change requires the Manage System Users right.</phrase>
<phrase data="auto_51aff18539497f49">User</phrase>
<phrase data="auto_533496be720f1840">Can log into the site and personal account.</phrase>
<phrase data="auto_54628e7921e2b7ef">Site user</phrase>
<phrase data="auto_7851fbc651bc0177">: &apos;&amp;#039;&apos; }[character];
});
},
deleteField: function (button) {
var self = this;
Adminx.Confirm.open({
kind: &apos;error&apos;, title: &apos;Delete field?&apos;,
message: &apos;Values of this field for site users will also be deleted.&apos;,
confirmLabel: &apos;Delete&apos;, confirmClass: &apos;btn-danger&apos;,
onConfirm: function () { self.request(button.getAttribute(&apos;data-url&apos;), new FormData()).then(function () { window.location.reload(); }); }
});
},
toggleField: function (input) {
var self = this;
var item = input.closest(&apos;[data-field-id]&apos;);
this.request(input.getAttribute(&apos;data-url&apos;), new FormData()).then(function (response) {
var active = !!Number(response.data &amp;&amp; response.data.is_active);
input.checked = active;
item.classList.toggle(&apos;is-disabled&apos;, !active);
var preview = document.querySelector(&apos;[data-preview-field=</phrase>
<phrase data="auto_8072db5006172c8c">Role rights are configured in the “Roles and Rights” section.</phrase>
<phrase data="auto_de7fb9566c3ab6f8">Action failed</phrase>
<phrase data="auto_f0dff5ab4a669f7a">Saved</phrase>
<phrase data="auto_f7198a183e197a24">The same account will be able to work in the administrative interface.</phrase>
</language>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="customers_center_title">Центр покупателей</phrase>
<phrase data="customers_center_eyebrow">Работа с покупателями</phrase>
<phrase data="customers_center_description">Аккаунт, покупки, обращения и интересы человека собраны в одной карточке.</phrase>
<phrase data="customers_center_accounts">Аккаунтов</phrase>
<phrase data="customers_center_buyers">Покупателей</phrase>
<phrase data="customers_center_repeat">Повторных</phrase>
<phrase data="customers_center_duplicates">Групп дублей</phrase>
<phrase data="customers_center_duplicate_warning">Найдены возможные дубли</phrase>
<phrase data="customers_center_duplicate_email">Одинаковый email</phrase>
<phrase data="customers_center_duplicate_phone">Одинаковый телефон</phrase>
<phrase data="customers_center_open">Открыть карточку</phrase>
<phrase data="customers_center_card">Карточка покупателя</phrase>
<phrase data="customers_center_card_hint">Профиль и история взаимодействия</phrase>
<phrase data="customers_center_orders">Заказы</phrase>
<phrase data="customers_center_login_methods">Способы входа</phrase>
<phrase data="customers_center_contacts">Обращения</phrase>
<phrase data="customers_center_notes">Заметки менеджеров</phrase>
<phrase data="customers_center_note_hint">Внутренняя информация, покупатель её не видит</phrase>
<phrase data="customers_center_note_new">Новая заметка</phrase>
<phrase data="customers_center_note_added">Заметка добавлена</phrase>
<phrase data="customers_center_merged">Аккаунты объединены</phrase>
<phrase data="customers_center_segment_all">Все</phrase>
<phrase data="customers_center_segment_new">Новые</phrase>
<phrase data="customers_center_segment_buyers">С заказами</phrase>
<phrase data="customers_center_segment_repeat">Повторные</phrase>
<phrase data="customers_center_segment_vip">От 100 000 ₽</phrase>
<phrase data="customers_center_segment_inactive">Неактивные</phrase>
<phrase data="customers_center_segment_empty">Без заказов</phrase>
</language>
@@ -2,30 +2,12 @@
<language>
<phrase data="auto_32b373146ceb643e">Не удалось загрузить профиль</phrase>
<phrase data="auto_3ad78d4634845d06">Новое поле</phrase>
<phrase data="auto_453d46b73a83a1f7">Изменение требует права управления системными пользователями.</phrase>
<phrase data="auto_51aff18539497f49">Пользователь</phrase>
<phrase data="auto_533496be720f1840">Может входить на сайт и в личный кабинет.</phrase>
<phrase data="auto_54628e7921e2b7ef">Пользователь сайта</phrase>
<phrase data="auto_7851fbc651bc0177">: &apos;&amp;#039;&apos; }[character];
});
},
deleteField: function (button) {
var self = this;
Adminx.Confirm.open({
kind: &apos;error&apos;, title: &apos;Удалить поле?&apos;,
message: &apos;Значения этого поля у пользователей сайта также будут удалены.&apos;,
confirmLabel: &apos;Удалить&apos;, confirmClass: &apos;btn-danger&apos;,
onConfirm: function () { self.request(button.getAttribute(&apos;data-url&apos;), new FormData()).then(function () { window.location.reload(); }); }
});
},
toggleField: function (input) {
var self = this;
var item = input.closest(&apos;[data-field-id]&apos;);
this.request(input.getAttribute(&apos;data-url&apos;), new FormData()).then(function (response) {
var active = !!Number(response.data &amp;&amp; response.data.is_active);
input.checked = active;
item.classList.toggle(&apos;is-disabled&apos;, !active);
var preview = document.querySelector(&apos;[data-preview-field=</phrase>
<phrase data="auto_8072db5006172c8c">Права роли настраиваются в разделе «Роли и права».</phrase>
<phrase data="auto_de7fb9566c3ab6f8">Не удалось выполнить действие</phrase>
<phrase data="auto_f0dff5ab4a669f7a">Сохранено</phrase>
<phrase data="auto_f7198a183e197a24">Та же учётная запись сможет работать в административном интерфейсе.</phrase>
</language>
@@ -0,0 +1,36 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Customers/migrations/007_customer_center.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Common\SystemTables;
use App\Content\BasketTables;
return function (array $context) {
$notes = SystemTables::prefix() . '_customer_notes';
if (!DatabaseSchema::tableExists($notes)) {
DB::query('CREATE TABLE `' . $notes . '` ('
. '`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,`user_id` INT UNSIGNED NOT NULL,`author_id` INT UNSIGNED NOT NULL DEFAULT 0,'
. '`note` TEXT NOT NULL,`created_at` INT UNSIGNED NOT NULL,PRIMARY KEY (`id`),KEY `idx_customer_notes` (`user_id`,`created_at`)'
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
}
$orders = BasketTables::table('module_basket_history');
if (DatabaseSchema::tableExists($orders) && !DatabaseSchema::indexExists($orders, 'idx_order_user_date')) {
DB::query('ALTER TABLE `' . $orders . '` ADD KEY `idx_order_user_date` (`order_user_id`,`order_published`)');
}
return 1;
};
+15 -1
View File
@@ -14,8 +14,10 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Adminx\Customers\GlobalSearchProvider;
return array(
'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.3.0',
'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.10.2',
'permissions' => array('key' => 'customers', 'items' => array(
array(
'code' => 'view_customers',
@@ -45,9 +47,13 @@
),
),
'routes' => array(
array('GET', '/system/customers/export', array(\App\Adminx\Customers\Controller::class, 'exportCustomers')),
array('GET', '/system/customers', array(\App\Adminx\Customers\Controller::class, 'index')),
array('POST', '/system/customers/saved-views', array(\App\Adminx\Customers\Controller::class, 'saveSavedView')),
array('POST', '/system/customers/saved-views/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteSavedView')),
array('GET', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'customer')),
array('POST', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'updateCustomer')),
array('POST', '/system/customers/users/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteCustomer')),
array('POST', '/system/customers/{id}/toggle', array(\App\Adminx\Customers\Controller::class, 'toggle')),
array('POST', '/system/customers/fields/reorder', array(\App\Adminx\Customers\Controller::class, 'reorderFields')),
array('POST', '/system/customers/fields/{id}', array(\App\Adminx\Customers\Controller::class, 'saveField')),
@@ -58,6 +64,9 @@
array('GET', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'authForm')),
array('POST', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'saveAuthForm')),
array('POST', '/system/customers/forms/{key}/reset', array(\App\Adminx\Customers\Controller::class, 'resetAuthForm')),
array('GET', '/system/customers/center/{id}', array(\App\Adminx\Customers\Controller::class, 'centerCustomer')),
array('POST', '/system/customers/center/{id}/notes', array(\App\Adminx\Customers\Controller::class, 'addCustomerNote')),
array('POST', '/system/customers/center/merge', array(\App\Adminx\Customers\Controller::class, 'mergeCustomers')),
),
'migrations' => array(
array('id' => '001_normalize_public_groups', 'file' => 'migrations/001_normalize_public_groups.sql'),
@@ -66,6 +75,11 @@
array('id' => '004_materialize_public_auth_schema', 'file' => 'migrations/004_materialize_public_auth_schema.sql'),
array('id' => '005_checkout_registration', 'file' => 'migrations/005_checkout_registration.sql'),
array('id' => '006_phone_identity', 'file' => 'migrations/006_phone_identity.php'),
array('id' => '007_customer_center', 'file' => 'migrations/007_customer_center.php'),
),
'view_globals' => array('module_code' => 'customers'),
'admin_extension' => array(
'url' => '/system/customers', 'feature' => 'Публичные аккаунты и профили', 'icon' => 'ti ti-user-heart',
'search' => array('code' => 'customers', 'provider' => array(GlobalSearchProvider::class, 'search'), 'permission' => 'view_customers', 'priority' => 26, 'limit' => 8),
),
);
@@ -0,0 +1,12 @@
<section class="customers-center-profile"><div class="customers-center-avatar">{{ (customer.user.firstname|first ~ customer.user.lastname|first)|upper ?: '#' }}</div><div><span class="section-eyebrow">Профиль #{{ customer.user.id }}</span><h2>{{ (customer.user.firstname ~ ' ' ~ customer.user.lastname)|trim ?: customer.user.user_name }}</h2><p>{{ customer.user.email ?: 'Email не указан' }}{% if customer.user.phone %} · {{ customer.user.phone }}{% endif %}</p><div class="customers-center-segment-list">{% set labels={'new':'Новый','buyers':'С заказами','repeat':'Повторный','vip':'От 100 000 ₽','inactive':'Неактивный','without_orders':'Без заказов'} %}{% for code in customer.segments %}<span class="badge {{ code=='vip'?'badge-violet':(code=='repeat'?'badge-green':(code=='inactive'?'badge-gray':'badge-blue')) }}">{{ labels[code] }}</span>{% endfor %}</div></div><a class="btn btn-secondary btn-sm" href="{{ ADMINX_BASE }}/system/customers?tab=customers&q={{ customer.user.id }}"><i class="ti ti-pencil"></i>Редактировать аккаунт</a></section>
<div class="customers-center-detail-kpis"><span><i class="ti ti-shopping-bag"></i><b>{{ customer.summary.orders_count }}</b><small>заказов</small></span><span><i class="ti ti-currency-rubel"></i><b>{{ customer.summary.orders_total|number_format(0,'.',' ') }} ₽</b><small>общая сумма</small></span><span><i class="ti ti-heart"></i><b>{{ customer.engagement.favorites|length }}</b><small>в избранном</small></span><span><i class="ti ti-history"></i><b>{{ customer.engagement.viewed|length }}</b><small>просмотрено</small></span></div>
<div class="customers-center-detail-grid">
<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile"><i class="ti ti-shopping-bag"></i></span><div><h3>Заказы</h3><p>Последние покупки пользователя</p></div></div>{% if customer.summary.guest_count %}<span class="badge badge-amber" data-tooltip="Оформлены без входа, найдены по email или телефону">Гостевых: {{ customer.summary.guest_count }}</span>{% endif %}</div><div class="customers-center-order-list">{% for order in customer.orders %}<a href="{{ ADMINX_BASE }}/shop/orders?tab=orders&q={{ order.order_id }}"><span><b>#{{ order.order_id }}</b><small>{{ order.order_published|date('d.m.Y H:i') }}{% if order.matched_by == 'contact' %} · <i class="ti ti-user-question"></i> гостевой{% endif %}</small></span><strong>{{ order.order_total|number_format(0,'.',' ') }} ₽</strong></a>{% else %}<div class="empty-state">Заказов пока нет</div>{% endfor %}</div></section>
<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-login"></i></span><div><h3>Способы входа</h3><p>Связанные аккаунты и подтверждения</p></div></div></div><div class="customers-center-identities"><article><i class="ti ti-{{ customer.user.email_verified_at?'mail-check':'mail' }}"></i><span><b>Email</b><small>{{ customer.user.email_verified_at?'подтверждён':'не подтверждён' }}</small></span></article>{% if customer.user.phone %}<article><i class="ti ti-{{ customer.user.phone_verified_at?'phone-check':'phone' }}"></i><span><b>Телефон</b><small>{{ customer.user.phone_verified_at?'подтверждён':'не подтверждён' }}</small></span></article>{% endif %}{% for identity in customer.identities %}<article><i class="ti {{ identity.provider=='yandex'?'ti-brand-yandex':(identity.provider=='vk'?'ti-brand-vk':'ti-key') }}"></i><span><b>{{ identity.provider|upper }}</b><small>последний вход {{ identity.last_login_at?identity.last_login_at|date('d.m.Y'):'—' }}</small></span></article>{% endfor %}</div></section>
<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--amber-100);--tile-fg:var(--amber-600)"><i class="ti ti-message"></i></span><div><h3>Обращения</h3><p>Сообщения контактных форм по email</p></div></div></div><div class="customers-center-contact-list">{% for item in customer.contacts %}<article><span><b>{{ item.subject ?: 'Обращение #' ~ item.id }}</b><small>{{ item.date|date('d.m.Y H:i') }}</small></span><span class="badge {{ item.status=='new'?'badge-amber':(item.status=='replied'?'badge-green':'badge-gray') }}">{{ item.status=='new'?'Новое':(item.status=='replied'?'Отвечено':'Просмотрено') }}</span></article>{% else %}<div class="empty-state">Обращений не найдено</div>{% endfor %}</div></section>
{% if customer.popup_leads %}<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-user-plus"></i></span><div><h3>Заявки из поп-апов</h3><p>Сопоставлены по email или телефону</p></div></div></div><div class="customers-center-contact-list">{% for item in customer.popup_leads %}<article><span><b>{{ item.campaign_title ?: 'Заявка #' ~ item.id }}</b><small>{{ item.created_at|date('d.m.Y H:i') }}{% if item.phone %} · {{ item.phone }}{% endif %}</small></span><span class="badge {{ item.status=='new'?'badge-amber':(item.status=='spam'?'badge-red':'badge-green') }}">{{ item.status=='new'?'Новая':(item.status=='spam'?'Спам':'Обработана') }}</span></article>{% endfor %}</div></section>{% endif %}
{% if customer.quiz_leads %}<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-list-check"></i></span><div><h3>Заявки из подбора</h3><p>Сопоставлены по email или телефону</p></div></div></div><div class="customers-center-contact-list">{% for item in customer.quiz_leads %}<article><span><b>{{ item.quiz_title }}</b><small>{{ item.created_label }} · ответов {{ item.answers_count }} · подошло {{ item.matches_count }}</small></span><span class="badge {{ item.lead_status=='new'?'badge-amber':(item.lead_status=='spam'?'badge-red':'badge-green') }}">{{ item.lead_status=='new'?'Новая':(item.lead_status=='spam'?'Спам':'Обработана') }}</span></article>{% endfor %}</div></section>{% endif %}
<section class="card ax-list-card"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-note"></i></span><div><h3>Заметки менеджеров</h3><p>Внутренняя информация, покупатель её не видит</p></div></div></div><div class="customers-center-notes">{% for note in customer.notes %}<article><p>{{ note.note }}</p><small>{{ note.author_name ?: 'Система' }} · {{ note.created_at|date('d.m.Y H:i') }}</small></article>{% else %}<div class="empty-state">Заметок пока нет</div>{% endfor %}</div>{% if can_manage %}<form class="customers-center-note-form" action="{{ ADMINX_BASE }}/system/customers/center/{{ customer.user.id }}/notes" data-customer-note data-refresh-url="{{ ADMINX_BASE }}/system/customers/center/{{ customer.user.id }}"><input type="hidden" name="_csrf" value="{{ csrf_token }}"><label class="field"><span class="field-label">Новая заметка</span><textarea class="textarea" name="note" rows="3" maxlength="4000" required></textarea></label><button class="btn btn-primary btn-sm" type="submit"><i class="ti ti-plus"></i>Добавить</button></form>{% endif %}</section>
</div>
@@ -0,0 +1,19 @@
<div class="section-header ax-panel-header customers-center-head"><div class="section-icon"><i class="ti ti-users-group"></i></div><div><div class="section-eyebrow">Работа с покупателями</div><h2>Центр покупателей</h2><p class="section-desc">Аккаунт, покупки, обращения и интересы человека собраны в одной карточке.</p></div><div class="section-header-right"><form class="customers-filter" action="{{ ADMINX_BASE }}/system/customers" data-customers-center-filter><input type="hidden" name="tab" value="center"><input type="hidden" name="segment" value="{{ segment|e }}"><div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" value="{{ q|e }}" placeholder="Имя, email, телефон или ID"></div><button class="btn btn-secondary"><i class="ti ti-search"></i>Найти</button></form>{% set saved_views_form = '[data-customers-center-filter]' %}{% set saved_views_save_url = ADMINX_BASE ~ '/system/customers/saved-views' %}{% set saved_views_delete_url = ADMINX_BASE ~ '/system/customers/saved-views/__ID__/delete' %}{% set saved_views_fields = ['q','segment'] %}{% include '@adminx/_saved_views.twig' %}</div></div>
<div class="customers-center-kpis">
<div class="card stat-tile"><div class="stat-ico"><i class="ti ti-users"></i></div><div><div class="stat-val">{{ center_stats.total|default(0) }}</div><div class="stat-label">Аккаунтов</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-green"><i class="ti ti-shopping-bag"></i></div><div><div class="stat-val">{{ center_stats.buyers|default(0) }}</div><div class="stat-label">Покупателей</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-blue"><i class="ti ti-repeat"></i></div><div><div class="stat-val">{{ center_stats.repeat|default(0) }}</div><div class="stat-label">Повторных</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-violet"><i class="ti ti-user-question"></i></div><div><div class="stat-val">{{ center_stats.duplicates|default(0) }}</div><div class="stat-label">Групп дублей</div></div></div>
</div>
<nav class="customers-segments" aria-label="Сегменты покупателей">{% for code,label in center_segments %}<a class="{{ segment==code?'is-active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=center&segment={{ code }}{% if q %}&q={{ q|url_encode }}{% endif %}">{{ label }}</a>{% endfor %}</nav>
{% if duplicate_groups %}<details class="alert alert-warning customers-duplicates"><summary><span><i class="ti ti-users-minus"></i><b>Найдены возможные дубли</b></span><span>{{ duplicate_groups|length }}</span></summary><div>{% for group in duplicate_groups %}<article><span><b>{{ group.kind=='email'?'Одинаковый email':'Одинаковый телефон' }}</b><small>{{ group.value }}</small></span><span class="mono">{% for id in group.ids %}#{{ id }}{% if not loop.last %}, {% endif %}{% endfor %}</span>{% if can_manage %}{% for id in group.ids %}{% if id!=group.target_id %}<button class="btn btn-secondary btn-sm" type="button" data-customer-merge data-target-id="{{ group.target_id }}" data-source-id="{{ id }}"><i class="ti ti-arrows-join"></i>Объединить #{{ id }} с #{{ group.target_id }}</button>{% endif %}{% endfor %}{% endif %}</article>{% endfor %}</div></details>{% endif %}
<section class="card ax-list-card customers-center-list">
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-address-book"></i></span><div><h2>Покупатели</h2><p class="text-secondary">Откройте карточку, чтобы увидеть всю историю взаимодействия.</p></div></div><span class="badge badge-blue">{{ center_customers|length }}</span></div>
<div class="table-scroll"><table class="table customers-center-table"><thead><tr><th>Покупатель</th><th>Заказы</th><th>Активность</th><th>Сегменты</th><th class="customers-actions-col">Действия</th></tr></thead><tbody>{% for customer in center_customers %}<tr data-customer-center-open data-url="{{ ADMINX_BASE }}/system/customers/center/{{ customer.id }}"><td><div class="customers-center-person"><span>{{ (customer.firstname|first ~ customer.lastname|first)|upper ?: '#' }}</span><div><b>{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}</b><small>{{ customer.email ?: customer.phone }} · #{{ customer.id }}</small></div></div></td><td><b>{{ customer.orders_count }} заказов</b><small>{{ customer.orders_total|number_format(0,'.',' ') }} ₽</small></td><td><b>{{ customer.last_order_at ? customer.last_order_at|date('d.m.Y') : 'Покупок нет' }}</b><small>Вход: {{ customer.last_visit ? customer.last_visit|date('d.m.Y') : 'не входил' }}</small></td><td><div class="customers-center-segment-list">{% for code in customer.segment_codes %}<span class="badge {{ code=='vip'?'badge-violet':(code=='repeat'?'badge-green':(code=='inactive'?'badge-gray':'badge-blue')) }}">{{ center_segments[code] }}</span>{% endfor %}</div></td><td><button class="btn btn-icon btn-ghost ax-act ax-act-view" type="button" data-tooltip="Открыть карточку" aria-label="Открыть карточку"><i class="ti ti-eye"></i></button></td></tr>{% else %}<tr><td colspan="5"><div class="empty-state"><i class="ti ti-user-search"></i><b>Покупатели не найдены</b><span>Измените поисковый запрос или сегмент.</span></div></td></tr>{% endfor %}</tbody></table></div>
</section>
<aside class="drawer drawer-right customer-center-drawer" id="customerCenterDrawer" role="dialog" aria-modal="true" aria-labelledby="customerCenterDrawerTitle" hidden><div class="drawer-header"><div><h3 id="customerCenterDrawerTitle">Карточка покупателя</h3><p class="text-secondary text-sm">Профиль и история взаимодействия</p></div><button class="btn btn-ghost btn-icon" type="button" data-close-drawer aria-label="Закрыть"><i class="ti ti-x"></i></button></div><div class="drawer-body customers-center-detail" data-customer-center-detail><div class="skeleton" style="height:160px"></div></div><div class="drawer-footer"><button class="btn btn-ghost" type="button" data-close-drawer>Закрыть</button></div></aside>
+32 -32
View File
@@ -5,26 +5,29 @@
<div class="page-header"><div><h1>Пользователи сайта</h1><p class="text-secondary">Публичные аккаунты, профили и способы входа</p></div></div>
<div class="ax-summary customers-summary">
{% if tab!='center' %}<div class="ax-summary customers-summary">
<div class="card stat-tile"><div class="stat-ico"><i class="ti ti-users"></i></div><div><div class="stat-val">{{ stats.total }}</div><div class="stat-label">Всего</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-green"><i class="ti ti-user-check"></i></div><div><div class="stat-val">{{ stats.active }}</div><div class="stat-label">Активные</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-blue"><i class="ti ti-mail-check"></i></div><div><div class="stat-val">{{ stats.verified }}</div><div class="stat-label">Email подтверждён</div></div></div>
<div class="card stat-tile"><div class="stat-ico is-violet"><i class="ti ti-forms"></i></div><div><div class="stat-val">{{ stats.fields }}</div><div class="stat-label">Доп. полей</div></div></div>
</div>
</div>{% endif %}
<nav class="tabs customers-tabs">
<a class="tab{{ tab=='center'?' active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=center"><i class="ti ti-users-group"></i>Центр покупателей</a>
<a class="tab{{ tab=='customers'?' active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=customers"><i class="ti ti-users"></i>Пользователи</a>
<a class="tab{{ tab=='fields'?' active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=fields"><i class="ti ti-forms"></i>Поля профиля</a>
<a class="tab{{ tab=='auth'?' active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=auth"><i class="ti ti-user-cog"></i>Регистрация</a>
<a class="tab{{ tab=='pages'?' active':'' }}" href="{{ ADMINX_BASE }}/system/customers?tab=pages"><i class="ti ti-browser"></i>Страницы входа</a>
</nav>
{% if tab=='customers' %}
<div class="section-header ax-panel-header"><div class="section-icon"><i class="ti ti-user-search"></i></div><div><div class="section-eyebrow">Аккаунты сайта</div><h2>Пользователи</h2></div><div class="section-header-right"><form class="customers-filter"><input type="hidden" name="tab" value="customers"><div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" value="{{ q|e }}" placeholder="Имя, email, телефон"></div><button class="btn btn-secondary"><i class="ti ti-filter"></i>Найти</button></form></div></div>
{% if tab=='center' %}
{% include '@customers/customer-center.twig' %}
{% elseif tab=='customers' %}
<div class="section-header ax-panel-header"><div class="section-icon"><i class="ti ti-user-search"></i></div><div><div class="section-eyebrow">Аккаунты сайта</div><h2>Пользователи</h2></div><div class="section-header-right"><form class="customers-filter"><input type="hidden" name="tab" value="customers"><div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" value="{{ q|e }}" placeholder="Имя, email, телефон"></div><button class="btn btn-secondary"><i class="ti ti-filter"></i>Найти</button></form><a class="btn btn-secondary" href="{{ ADMINX_BASE }}/system/customers/export?q={{ q|url_encode }}"><i class="ti ti-file-spreadsheet"></i>CSV</a></div></div>
<div class="card ax-list-card">
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-users-group"></i></span><div><h2>Публичные аккаунты</h2><p class="text-secondary">Посетители, зарегистрированные на сайте, их контакты и доступ к личному кабинету.</p></div></div><span class="badge badge-blue">{{ customers|length }}</span></div>
<div class="table-scroll"><table class="table customers-table"><thead><tr><th>ID</th><th>Пользователь</th><th>Контакты</th><th>Компания</th><th>Регистрация</th><th>Статус</th>{% if can_manage %}<th class="customers-actions-col">Действия</th>{% endif %}</tr></thead><tbody>
{% for customer in customers %}<tr data-customer-row data-id="{{ customer.id }}"><td class="mono text-muted">{{ customer.id }}</td><td data-customer-name><b>{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}</b><small class="mono">{{ customer.user_name }}</small></td><td data-customer-contacts><span>{{ customer.email ?: customer.phone }}</span>{% if customer.email and customer.phone %}<small>{{ customer.phone }}</small>{% endif %}</td><td data-customer-company>{{ customer.company ?: '—' }}</td><td class="mono text-sm">{{ customer.reg_time ? customer.reg_time|date('d.m.Y') : '—' }}</td><td>{% if can_manage %}<label class="switch" data-tooltip="Изменить активность"><input type="checkbox" data-customer-toggle data-url="{{ ADMINX_BASE }}/system/customers/{{ customer.id }}/toggle"{{ customer.status=='1'?' checked':'' }}><span></span></label>{% else %}<span class="badge {{ customer.status=='1'?'badge-green':'badge-gray' }}">{{ customer.status=='1'?'Активен':'Отключён' }}</span>{% endif %}</td>{% if can_manage %}<td><button class="btn btn-icon btn-ghost ax-act ax-act-edit" type="button" data-customer-edit data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}" data-tooltip="Редактировать" aria-label="Редактировать"><i class="ti ti-pencil"></i></button></td>{% endif %}</tr>
{% for customer in customers %}<tr data-customer-row data-id="{{ customer.id }}"><td class="mono text-muted">{{ customer.id }}</td><td data-customer-name><b>{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}</b><small class="mono">{{ customer.user_name }}</small></td><td data-customer-contacts><span>{{ customer.email ?: customer.phone }}</span>{% if customer.email and customer.phone %}<small>{{ customer.phone }}</small>{% endif %}</td><td data-customer-company>{{ customer.company ?: '—' }}</td><td class="mono text-sm">{{ customer.reg_time ? customer.reg_time|date('d.m.Y') : '—' }}</td><td>{% if can_manage %}<label class="switch" data-tooltip="{{ customer.id==current_public_user_id ? 'Собственную учётную запись нельзя отключить' : 'Изменить активность' }}"><input type="checkbox" data-customer-toggle data-url="{{ ADMINX_BASE }}/system/customers/{{ customer.id }}/toggle"{{ customer.status=='1'?' checked':'' }}{{ customer.id==current_public_user_id?' disabled':'' }}><span></span></label>{% else %}<span class="badge {{ customer.status=='1'?'badge-green':'badge-gray' }}">{{ customer.status=='1'?'Активен':'Отключён' }}</span>{% endif %}</td>{% if can_manage %}<td><button class="btn btn-icon btn-ghost ax-act ax-act-edit" type="button" data-customer-edit data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}" data-tooltip="Редактировать" aria-label="Редактировать"><i class="ti ti-pencil"></i></button>{% if customer.id==current_public_user_id %}<button class="btn btn-icon btn-ghost ax-act" type="button" disabled data-tooltip="Собственную учётную запись удалить нельзя" aria-label="Удаление недоступно"><i class="ti ti-lock"></i></button>{% else %}<button class="btn btn-icon btn-ghost ax-act ax-act-danger" type="button" data-customer-delete data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}/delete" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>{% endif %}</td>{% endif %}</tr>
{% else %}<tr><td colspan="{{ can_manage ? 7 : 6 }}"><div class="empty-state">Пользователи не найдены</div></td></tr>{% endfor %}
</tbody></table></div>
</div>
@@ -70,17 +73,17 @@
<section class="card customers-auth-section">
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-user-plus"></i></span><div><h2>Создание аккаунта</h2><p class="text-secondary">Кто может зарегистрироваться и когда станет доступен вход.</p></div></div><label class="switch" data-tooltip="Разрешить регистрацию"><input type="checkbox" name="registration_enabled" value="1"{{ auth_settings.registration_enabled ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label></div>
<div class="customers-auth-settings-grid">
<label class="field"><span class="field-label">Активация аккаунта</span><select class="select" name="registration_mode" data-registration-mode{{ can_manage ? '' : ' disabled' }}><option value="email"{{ auth_settings.registration_mode=='email' ? ' selected' : '' }}>После подтверждения email</option><option value="now"{{ auth_settings.registration_mode=='now' ? ' selected' : '' }}>Сразу после регистрации</option><option value="byadmin"{{ auth_settings.registration_mode=='byadmin' ? ' selected' : '' }}>После одобрения администратором</option></select><span class="field-hint">Для режима одобрения включите пользователя в первом табе.</span></label>
<label class="field"><span class="field-label">Способы регистрации</span><select class="select" name="registration_gate" data-registration-gate{{ can_manage ? '' : ' disabled' }}><option value="email"{{ auth_settings.registration_gate=='email' ? ' selected' : '' }}>По email</option><option value="phone"{{ auth_settings.registration_gate=='phone' ? ' selected' : '' }}>По телефону</option><option value="email_phone"{{ auth_settings.registration_gate=='email_phone' ? ' selected' : '' }}>По email или телефону</option></select><span class="field-hint">Телефонная регистрация появится после подключения SMS-провайдера. Email для такого аккаунта не требуется.</span></label>
<label class="field" data-auth-email-registration><span class="field-label">Активация регистрации по email</span><select class="select" name="registration_mode" data-registration-mode{{ can_manage ? '' : ' disabled' }}><option value="email"{{ auth_settings.registration_mode=='email' ? ' selected' : '' }}>После подтверждения email</option><option value="now"{{ auth_settings.registration_mode=='now' ? ' selected' : '' }}>Сразу после регистрации</option><option value="byadmin"{{ auth_settings.registration_mode=='byadmin' ? ' selected' : '' }}>После одобрения администратором</option></select><span class="field-hint">Телефон уже подтверждается одноразовым SMS-кодом.</span></label>
<label class="field"><span class="field-label">Группа после регистрации</span><select class="select" name="default_group"{{ can_manage ? '' : ' disabled' }}>{% for group in customer_groups %}<option value="{{ group.id }}"{{ auth_settings.default_group==group.id ? ' selected' : '' }}>#{{ group.id }} · {{ group.name }} · {{ group.permissions_count }} разрешений</option>{% endfor %}</select><span class="field-hint">Вы сами выбираете группу, которая будет назначаться новым пользователям сайта.</span></label>
<div class="field customers-auth-fixed"><span class="field-label">Канал регистрации</span><div class="customers-auth-fixed-value"><i class="ti ti-mail"></i><span><b>Email</b><small>Телефон и SMS подключаются отдельным гейтом.</small></span></div></div>
</div>
</section>
<section class="card customers-auth-section">
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-forms"></i></span><div><h2>Поля формы регистрации</h2><p class="text-secondary">Базовые данные аккаунта. Дополнительные поля подключаются в соседнем табе.</p></div></div></div>
<section class="card customers-auth-section" data-auth-email-form-fields>
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-forms"></i></span><div><h2>Поля регистрации по email</h2><p class="text-secondary">Для регистрации по телефону достаточно номера и SMS-кода. Остальные данные пользователь заполнит в профиле.</p></div></div></div>
<div class="customers-auth-fields" data-auth-fields>
<div class="customers-auth-fields-head"><span>Поле</span><span>Показывать</span><span>Обязательное</span></div>
<div class="customers-auth-field-row"><span><i class="ti ti-mail"></i><span><b>Email</b><small>Логин и канал подтверждения</small></span></span><span class="badge badge-blue">Всегда</span><span class="badge badge-blue">Всегда</span></div>
<div class="customers-auth-field-row"><span><i class="ti ti-mail"></i><span><b>Email</b><small data-auth-email-field-description>{{ auth_settings.registration_gate == 'email_phone' ? 'Запрашивается только при выборе регистрации по email' : 'Логин и канал подтверждения регистрации по email' }}</small></span></span><span class="badge badge-blue" data-auth-email-visibility>{{ auth_settings.registration_gate == 'email_phone' ? 'В email-форме' : 'Показывается' }}</span><span class="badge {{ auth_settings.registration_gate == 'email_phone' ? 'badge-gray' : 'badge-blue' }}" data-auth-email-required>{{ auth_settings.registration_gate == 'email_phone' ? 'По выбору способа' : 'Обязательно' }}</span></div>
<div class="customers-auth-field-row"><span><i class="ti ti-user"></i><span><b>Имя</b><small>Основное имя пользователя</small></span></span><span class="badge badge-blue">Всегда</span><label class="switch"><input type="checkbox" name="require_firstname" value="1"{{ auth_settings.require_firstname ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label></div>
<div class="customers-auth-field-row" data-auth-field-row><span><i class="ti ti-id"></i><span><b>Фамилия</b><small>Дополняет отображаемое имя</small></span></span><label class="switch"><input type="checkbox" name="show_lastname" value="1" data-auth-show{{ auth_settings.show_lastname ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label><label class="switch"><input type="checkbox" name="require_lastname" value="1" data-auth-required{{ auth_settings.require_lastname ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label></div>
<div class="customers-auth-field-row" data-auth-field-row><span><i class="ti ti-phone"></i><span><b>Телефон</b><small>Контакт для заказов и доставки</small></span></span><label class="switch"><input type="checkbox" name="show_phone" value="1" data-auth-show{{ auth_settings.show_phone ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label><label class="switch"><input type="checkbox" name="require_phone" value="1" data-auth-required{{ auth_settings.require_phone ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label></div>
@@ -88,7 +91,7 @@
</div>
</section>
<section class="card customers-auth-section">
<section class="card customers-auth-section" data-auth-email-settings>
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--amber-100);--tile-fg:var(--amber-600)"><i class="ti ti-shield-lock"></i></span><div><h2>Пароль и подтверждение</h2><p class="text-secondary">Срок действия одноразовых ссылок и требования к паролю.</p></div></div></div>
<div class="customers-auth-settings-grid">
<label class="field"><span class="field-label">Минимальная длина пароля</span><input class="input" type="number" name="password_min_length" min="8" max="72" value="{{ auth_settings.password_min_length }}"{{ can_manage ? '' : ' disabled' }}></label>
@@ -98,7 +101,7 @@
</div>
</section>
<section class="card customers-auth-section customers-checkout-registration">
<section class="card customers-auth-section customers-checkout-registration" data-auth-email-settings>
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-shopping-bag-check"></i></span><div><h2>Аккаунт после заказа</h2><p class="text-secondary">Покупатель может создать тот же личный кабинет прямо при оформлении.</p></div></div><label class="switch" data-tooltip="Предлагать регистрацию в корзине"><input type="checkbox" name="checkout_registration_enabled" value="1"{{ auth_settings.checkout_registration_enabled ? ' checked' : '' }}{{ can_manage ? '' : ' disabled' }}><span></span></label></div>
<div class="customers-checkout-body">
<div class="alert alert-info customers-checkout-note"><i class="ti ti-lock-check"></i><p>Пароль по почте не отправляется. После заказа пользователь сразу входит в аккаунт и получает одноразовую ссылку, чтобы задать пароль самостоятельно.</p></div>
@@ -115,7 +118,7 @@
</div>
</section>
<section class="card customers-auth-section">
<section class="card customers-auth-section" data-auth-email-settings>
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-shield-x"></i></span><div><h2>Ограничения регистрации</h2><p class="text-secondary">Один email или домен на строку. Проверка не зависит от регистра букв.</p></div></div></div>
<div class="customers-auth-restrictions"><label class="field"><span class="field-label">Запрещённые домены</span><textarea class="textarea mono" name="deny_domains" rows="5" placeholder="example.org&#10;mail.invalid"{{ can_manage ? '' : ' disabled' }}>{{ auth_settings.deny_domains|e }}</textarea></label><label class="field"><span class="field-label">Запрещённые адреса</span><textarea class="textarea mono" name="deny_emails" rows="5" placeholder="blocked@example.org"{{ can_manage ? '' : ' disabled' }}>{{ auth_settings.deny_emails|e }}</textarea></label></div>
</section>
@@ -123,14 +126,13 @@
{% if can_manage %}<div class="customers-auth-actions"><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить настройки</button></div>{% endif %}
</form>
{% if oauth_modules %}
<div class="section-header customers-social-head">
<div class="section-icon"><i class="ti ti-login-2"></i></div>
<div><div class="section-eyebrow">Авторизация</div><h2>Подключённые способы входа</h2><p class="section-desc">OAuth и вход по одноразовому коду используют одну учётную запись сайта.</p></div>
<div class="section-header-right"><span class="badge badge-blue">{{ oauth_modules|length }} подключено</span></div>
<div><div class="section-eyebrow">Авторизация</div><h2>Способы входа</h2><p class="section-desc">Яндекс, VK ID и SMS-код подключаются к одной учётной записи сайта.</p></div>
<div class="section-header-right"><span class="badge badge-green">{{ auth_method_stats.active }} активно</span><span class="badge badge-gray">{{ auth_method_stats.installed }} установлено</span></div>
</div>
<div class="customers-social-grid">
{% for provider in oauth_modules %}
{% for provider in auth_methods %}
<section class="card customers-social-card">
<div class="ax-list-head">
<div class="ax-list-title"><span class="customers-social-mark {{ provider.brand }}">{{ provider.mark }}</span><div><h3>{{ provider.label }}</h3><p class="text-secondary">{{ provider.description }}</p></div></div>
@@ -138,19 +140,17 @@
</div>
<div class="customers-social-status">
<div class="customers-social-checks">
<span class="{{ provider.module_enabled ? 'is-ready' : '' }}"><i class="ti {{ provider.module_enabled ? 'ti-circle-check' : 'ti-circle-x' }}"></i>Модуль включён</span>
<span class="{{ provider.configured ? 'is-ready' : '' }}"><i class="ti {{ provider.configured ? 'ti-key' : 'ti-key-off' }}"></i>Ключи настроены</span>
<span class="{{ provider.installed ? 'is-ready' : '' }}"><i class="ti {{ provider.installed ? 'ti-package' : 'ti-package-off' }}"></i>Модуль {{ provider.installed ? 'установлен' : 'не установлен' }}</span>
<span class="{{ provider.module_enabled ? 'is-ready' : '' }}"><i class="ti {{ provider.module_enabled ? 'ti-circle-check' : 'ti-circle-x' }}"></i>Модуль {{ provider.module_enabled ? 'включён' : 'выключен' }}</span>
<span class="{{ provider.configured ? 'is-ready' : '' }}"><i class="ti {{ provider.configured ? 'ti-key' : 'ti-key-off' }}"></i>Подключение {{ provider.configured ? 'настроено' : 'не настроено' }}</span>
<span class="{{ provider.allow_registration ? 'is-ready' : '' }}"><i class="ti ti-user-plus"></i>Регистрация {{ provider.allow_registration ? 'разрешена' : 'выключена' }}</span>
</div>
{% if provider.can_open %}<a class="btn btn-secondary btn-sm" href="{{ ADMINX_BASE }}{{ provider.url }}"><i class="ti ti-settings"></i>Настроить</a>{% endif %}
{% if provider.can_open %}<a class="btn btn-secondary btn-sm" href="{{ ADMINX_BASE }}{{ provider.action_url }}"><i class="ti {{ provider.installed ? 'ti-settings' : 'ti-puzzle' }}"></i>{{ provider.action_label }}</a>{% endif %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info customers-auth-note"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-plug-connected"></i></span><div><b>Дополнительные способы входа устанавливаются модулями</b><p>Яндекс, VK ID и SMSC настраиваются отдельно в разделе «Модули». Удаление пакета физически убирает его код и форму настроек.</p></div><a class="btn btn-secondary" href="{{ ADMINX_BASE }}/modules"><i class="ti ti-puzzle"></i>Открыть модули</a></div>
{% endif %}
{% else %}
<div class="section-header ax-panel-header customers-pages-head"><div class="section-icon"><i class="ti ti-browser"></i></div><div><div class="section-eyebrow">Публичный сайт</div><h2>Страницы личного кабинета</h2><p class="section-desc">Каждая страница имеет редактируемый Twig-шаблон и выводится через `[tag:maincontent]` внутри выбранного шаблона сайта.</p></div>{% if can_manage %}<div class="section-header-right"><button class="btn btn-primary" type="submit" form="customerPagesForm"><i class="ti ti-device-floppy"></i>Сохранить</button></div>{% endif %}</div>
<div class="alert alert-info customers-auth-note"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-route"></i></span><div><b>Старые адреса продолжат работать</b><p>После смены URL системные `/login`, `/register`, `/remember`, `/password/reset` и `/personal` останутся совместимыми алиасами. Новые ссылки в интерфейсе и письмах сразу начнут использовать заданные адреса.</p></div></div>
@@ -165,7 +165,7 @@
</section>
{% endfor %}
</div>
<section class="card customers-auth-section customers-service-routes"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--gray-100);--tile-fg:var(--gray-700)"><i class="ti ti-code"></i></span><div><h2>Служебные маршруты</h2><p class="text-secondary">Подтверждение email, выход и общий шаблон системных результатов.</p></div></div>{% if can_manage %}<div class="customers-service-actions"><button class="btn btn-secondary btn-sm" type="button" data-auth-form-edit="message"><i class="ti ti-message-circle-check"></i>Сообщения</button></div>{% endif %}</div><div class="customers-auth-restrictions"><label class="field"><span class="field-label">Подтверждение email</span><span class="input-wrap"><i class="ti ti-link"></i><input class="input mono" name="pages[verify][path]" value="{{ auth_settings.pages.verify.path }}" required></span><input type="hidden" name="pages[verify][title]" value=""><input type="hidden" name="pages[verify][description]" value=""><input type="hidden" name="pages[verify][submit_label]" value=""><input type="hidden" name="pages[verify][template_id]" value="1"></label><label class="field"><span class="field-label">Выход</span><span class="input-wrap"><i class="ti ti-link"></i><input class="input mono" name="pages[logout][path]" value="{{ auth_settings.pages.logout.path }}" required></span><input type="hidden" name="pages[logout][title]" value=""><input type="hidden" name="pages[logout][description]" value=""><input type="hidden" name="pages[logout][submit_label]" value=""><input type="hidden" name="pages[logout][template_id]" value="1"></label></div></section>
<section class="card customers-auth-section customers-service-routes"><div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--gray-100);--tile-fg:var(--gray-700)"><i class="ti ti-code"></i></span><div><h2>Служебные маршруты и фрагменты</h2><p class="text-secondary">Подтверждение, выход, панель пользователя и подключаемые способы входа.</p></div></div>{% if can_manage %}<div class="customers-service-actions">{% for key in ['panel','message','phone','oauth','oauth_connections'] %}{% set form_meta = auth_forms[key] %}<button class="btn btn-secondary btn-sm" type="button" data-auth-form-edit="{{ key }}"><i class="ti {{ form_meta.icon }}"></i>{{ form_meta.label }}</button>{% endfor %}</div>{% endif %}</div><div class="customers-auth-restrictions"><label class="field"><span class="field-label">Подтверждение email</span><span class="input-wrap"><i class="ti ti-link"></i><input class="input mono" name="pages[verify][path]" value="{{ auth_settings.pages.verify.path }}" required></span><input type="hidden" name="pages[verify][title]" value=""><input type="hidden" name="pages[verify][description]" value=""><input type="hidden" name="pages[verify][submit_label]" value=""><input type="hidden" name="pages[verify][template_id]" value="1"></label><label class="field"><span class="field-label">Выход</span><span class="input-wrap"><i class="ti ti-link"></i><input class="input mono" name="pages[logout][path]" value="{{ auth_settings.pages.logout.path }}" required></span><input type="hidden" name="pages[logout][title]" value=""><input type="hidden" name="pages[logout][description]" value=""><input type="hidden" name="pages[logout][submit_label]" value=""><input type="hidden" name="pages[logout][template_id]" value="1"></label></div></section>
{% if can_manage %}<div class="customers-auth-actions"><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить страницы</button></div>{% endif %}
</form>
{% endif %}
@@ -173,27 +173,27 @@
{% if can_manage %}
<aside class="drawer drawer-right customer-editor-drawer" id="customerEditorDrawer" role="dialog" aria-modal="true" aria-labelledby="customerEditorTitle" hidden>
<div class="drawer-header"><div><h3 id="customerEditorTitle" data-customer-editor-title>Пользователь сайта</h3><p>Профиль, доступ и данные личного кабинета</p></div><button class="btn btn-icon btn-ghost" type="button" data-close-drawer aria-label="Закрыть"><i class="ti ti-x"></i></button></div>
<form data-customer-editor data-base="{{ ADMINX_BASE }}">
<form data-customer-editor data-base="{{ ADMINX_BASE }}" data-can-manage-admin-access="{{ can_manage_admin_access ? '1' : '0' }}">
<div class="drawer-body">
<div class="customers-editor-meta"><span><i class="ti ti-id"></i><b data-customer-meta-id>#—</b></span><span><i class="ti ti-user-plus"></i><b data-customer-meta-created>—</b><small>Регистрация</small></span><span><i class="ti ti-clock"></i><b data-customer-meta-visit>—</b><small>Последний вход</small></span></div>
<div class="customers-editor-sections">
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-user"></i></span><div><b>Основные данные</b><span>Имя, контакты и данные организации.</span></div></div><div class="form-grid"><label class="field col-6"><span class="field-label">Имя</span><input class="input" name="firstname" maxlength="50"></label><label class="field col-6"><span class="field-label">Фамилия</span><input class="input" name="lastname" maxlength="50"></label><label class="field col-6"><span class="field-label">Email</span><input class="input" type="email" name="email" maxlength="100" required></label><label class="field col-6"><span class="field-label">Логин</span><input class="input mono" name="user_name" maxlength="50" required><span class="field-hint">Можно использовать для входа наравне с email.</span></label><label class="field col-6"><span class="field-label">Телефон</span><input class="input" type="tel" name="phone" maxlength="50"></label><label class="field col-6"><span class="field-label">Компания</span><input class="input" name="company" maxlength="255"></label><label class="field col-4"><span class="field-label">Дата рождения</span><input class="input" type="date" name="birthday"></label><label class="field col-8"><span class="field-label">О пользователе</span><textarea class="textarea" name="description" rows="2"></textarea></label></div></section>
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-map-pin"></i></span><div><b>Адрес</b><span>Данные для профиля и предзаполнения заказов.</span></div></div><div class="form-grid"><label class="field col-4"><span class="field-label">Город</span><input class="input" name="city"></label><label class="field col-4"><span class="field-label">Улица</span><input class="input" name="street"></label><label class="field col-2"><span class="field-label">Дом</span><input class="input" name="street_nr"></label><label class="field col-2"><span class="field-label">Индекс</span><input class="input" name="zipcode"></label></div></section>
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-user"></i></span><div><b>Основные данные</b><span>Имя, контакты и данные организации.</span></div></div><div class="form-grid"><label class="field col-6"><span class="field-label">Имя</span><span class="input-wrap customers-editor-input"><i class="ti ti-user"></i><input class="input" name="firstname" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Фамилия</span><span class="input-wrap customers-editor-input"><i class="ti ti-user"></i><input class="input" name="lastname" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Email</span><span class="input-wrap customers-editor-input"><i class="ti ti-mail"></i><input class="input" type="email" name="email" maxlength="100" required></span></label><label class="field col-6"><span class="field-label">Логин</span><span class="input-wrap customers-editor-input"><i class="ti ti-at"></i><input class="input mono" name="user_name" maxlength="50" required></span><span class="field-hint">Можно использовать для входа наравне с email.</span></label><label class="field col-6"><span class="field-label">Телефон</span><span class="input-wrap customers-editor-input"><i class="ti ti-phone"></i><input class="input" type="tel" name="phone" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Компания</span><span class="input-wrap customers-editor-input"><i class="ti ti-building"></i><input class="input" name="company" maxlength="255"></span></label><label class="field col-4"><span class="field-label">Дата рождения</span><span class="input-wrap customers-editor-input"><i class="ti ti-calendar"></i><input class="input" type="date" name="birthday"></span></label><label class="field col-8"><span class="field-label">О пользователе</span><textarea class="textarea" name="description" rows="2"></textarea></label></div></section>
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-map-pin"></i></span><div><b>Адрес</b><span>Данные для профиля и предзаполнения заказов.</span></div></div><div class="form-grid"><label class="field col-4"><span class="field-label">Город</span><span class="input-wrap customers-editor-input"><i class="ti ti-building-community"></i><input class="input" name="city"></span></label><label class="field col-4"><span class="field-label">Улица</span><span class="input-wrap customers-editor-input"><i class="ti ti-road"></i><input class="input" name="street"></span></label><label class="field col-2"><span class="field-label">Дом</span><span class="input-wrap customers-editor-input"><i class="ti ti-home"></i><input class="input" name="street_nr"></span></label><label class="field col-2"><span class="field-label">Индекс</span><span class="input-wrap customers-editor-input"><i class="ti ti-mailbox"></i><input class="input" name="zipcode"></span></label></div></section>
<section class="customers-form-section">
<div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-shield-check"></i></span><div><b>Доступ</b><span>Публичная группа, состояние аккаунта и роль в панели управления.</span></div></div>
<div class="form-grid">
<label class="field col-6"><span class="field-label">Группа публичных пользователей</span><select class="select" name="user_group" required>{% for group in customer_groups %}<option value="{{ group.id }}">#{{ group.id }} · {{ group.name }} · {{ group.permissions_count }} разрешений</option>{% endfor %}</select></label>
<label class="field col-6" data-customer-admin-role><span class="field-label">Роль в панели управления</span><select class="select" name="admin_role"{{ can_manage_admin_access ? '' : ' disabled' }}>{% for code, label in admin_roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select><span class="field-hint">Права роли настраиваются в разделе «Роли и права».</span></label>
<label class="field col-6"><span class="field-label">Группа публичных пользователей</span><select class="select" name="user_group" required>{% for group in customer_groups %}<option value="{{ group.id }}">#{{ group.id }} · {{ group.name }} · {{ group.permissions_count }} разрешений</option>{% endfor %}</select><span class="field-hint" data-customer-group-hint>Определяет права пользователя на публичной части сайта.</span></label>
<label class="field col-6" data-customer-admin-role><span class="field-label">Роль в панели управления</span><select class="select" name="admin_role"{{ can_manage_admin_access ? '' : ' disabled' }}>{% for code, label in admin_roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select><span class="field-hint" data-customer-admin-role-hint>Права роли настраиваются в разделе «Роли и права».</span></label>
</div>
<div class="customers-switch-grid customers-editor-access">
<label class="customers-switch-card"><span><b>Аккаунт включён</b><small>Может входить на сайт и в личный кабинет.</small></span><span class="switch"><input type="checkbox" name="status" value="1"><span></span></span></label>
<label class="customers-switch-card"><span><b>Доступ в панель управления</b><small>{{ can_manage_admin_access ? 'Та же учётная запись сможет работать в административном интерфейсе.' : 'Изменение требует права управления системными пользователями.' }}</small></span><span class="switch"><input type="checkbox" name="admin_access" value="1" data-customer-admin-access{{ can_manage_admin_access ? '' : ' disabled' }}><span></span></span></label>
<label class="customers-switch-card" data-customer-account-card><span><b>Аккаунт включён</b><small data-customer-account-hint>Может входить на сайт и в личный кабинет.</small></span><span class="switch"><input type="checkbox" name="status" value="1"><span></span></span></label>
<label class="customers-switch-card" data-customer-admin-card><span><b>Доступ в панель управления</b><small data-customer-admin-hint>{{ can_manage_admin_access ? 'Та же учётная запись сможет работать в административном интерфейсе.' : 'Изменение требует права управления системными пользователями.' }}</small></span><span class="switch"><input type="checkbox" name="admin_access" value="1" data-customer-admin-access{{ can_manage_admin_access ? '' : ' disabled' }}><span></span></span></label>
<label class="customers-switch-card"><span><b>Email подтверждён</b><small>Администратор может изменить статус вручную.</small></span><span class="switch"><input type="checkbox" name="email_verified" value="1"><span></span></span></label>
<label class="customers-switch-card"><span><b>Телефон подтверждён</b><small>Подготовлено для будущего SMS-гейта.</small></span><span class="switch"><input type="checkbox" name="phone_verified" value="1"><span></span></span></label>
</div>
</section>
{% if fields %}<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-forms"></i></span><div><b>Дополнительные поля</b><span>Активные поля из конструктора публичного профиля.</span></div></div><div class="form-grid">{% for field in fields %}{% if field.is_active %}<label class="field col-6"><span class="field-label">{{ field.name }}{% if field.is_required %} <span class="text-danger">*</span>{% endif %}</span>{% if field.type=='textarea' %}<textarea class="textarea" name="extra[{{ field.id }}]" rows="3"{{ field.is_required?' required':'' }}></textarea>{% elseif field.type=='select' %}<select class="select" name="extra[{{ field.id }}]"{{ field.is_required?' required':'' }}><option value="">Выберите значение</option>{% for choice in field.choices %}<option value="{{ choice|e('html_attr') }}">{{ choice }}</option>{% endfor %}</select>{% elseif field.type=='checkbox' %}<span class="customers-editor-checkbox"><span class="switch"><input type="checkbox" name="extra[{{ field.id }}]" value="1"><span></span></span><span>Да / нет</span></span>{% else %}<input class="input" type="{{ field.type in ['date','number','email','tel'] ? field.type : 'text' }}" name="extra[{{ field.id }}]"{{ field.is_required?' required':'' }}>{% endif %}<span class="field-hint mono">{{ field.code }}</span></label>{% endif %}{% endfor %}</div></section>{% endif %}
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-key"></i></span><div><b>Безопасность</b><span>Пароль изменится, только если заполнить поле.</span></div></div><label class="field customers-editor-password"><span class="field-label">Новый пароль</span><input class="input" type="password" name="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><span class="field-hint">При смене пароля будут отозваны remember-сессии и ссылки восстановления.</span></label></section>
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-key"></i></span><div><b>Безопасность</b><span>Пароль изменится, только если заполнить поле.</span></div></div><label class="field customers-editor-password"><span class="field-label">Новый пароль</span><span class="input-wrap customers-editor-input"><i class="ti ti-lock"></i><input class="input" type="password" name="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"></span><span class="field-hint">При смене пароля будут отозваны remember-сессии и ссылки восстановления.</span></label></section>
</div>
</div>
<footer class="drawer-footer"><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить</button></footer>
+68 -1
View File
@@ -16,6 +16,7 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Settings;
use DB;
/**
@@ -25,11 +26,67 @@
*/
class Backup
{
/** Ключ настройки «сколько копий хранить»; 0 — хранить все. */
const KEEP_SETTING = 'database_backup_keep';
const KEEP_DEFAULT = 10;
const KEEP_MAX = 200;
public static function dir()
{
return BASEPATH . DS . 'tmp' . DS . 'backup';
}
public static function keep()
{
$value = Settings::get(self::KEEP_SETTING);
if ($value === null || $value === '') { return self::KEEP_DEFAULT; }
return max(0, min(self::KEEP_MAX, (int) $value));
}
public static function setKeep($value)
{
$value = max(0, min(self::KEEP_MAX, (int) $value));
Settings::set(self::KEEP_SETTING, $value, 'integer');
return $value;
}
/**
* Оставляет только последние $keep копий, созданных системой.
*
* Загруженные вручную и чужие файлы не трогаются: их имя не проходит
* isManagedName(), и админ мог положить их сюда намеренно. Страховочные
* копии перед восстановлением тоже участвуют в ротации — иначе каждое
* восстановление оставляло бы вечный файл.
*/
public static function prune($keep = null)
{
$keep = $keep === null ? self::keep() : $keep;
$names = array();
foreach (self::prunable(self::all(), $keep) as $name) {
$path = self::path($name);
if ($path !== null && @unlink($path)) { $names[] = $name; }
}
return array('deleted' => count($names), 'names' => $names);
}
/**
* Отбирает имена лишних копий: чистая функция, файлы не трогает.
* Список приходит из all() и уже отсортирован «новые сверху».
*/
public static function prunable(array $items, $keep)
{
$keep = max(0, min(self::KEEP_MAX, (int) $keep));
if ($keep < 1) { return array(); }
$managed = array();
foreach ($items as $item) {
if (!empty($item['own'])) { $managed[] = (string) $item['name']; }
}
return count($managed) <= $keep ? array() : array_slice($managed, $keep);
}
/** Список файлов резервных копий (name/size/mtime), новые сверху. */
public static function all()
{
@@ -202,7 +259,17 @@
flock($lock, LOCK_UN);
fclose($lock);
return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes);
//-- Ротация только после успешного переименования: сначала новая
//-- копия на диске, и лишь потом удаляются лишние старые.
$pruned = self::prune();
return array(
'name' => $name,
'size' => filesize($path),
'tables' => count($tables),
'prefixes' => $prefixes,
'pruned' => (int) $pruned['deleted'],
);
}
/** Новые имена AVE.cms и старые adminx-дампы поддерживаются одинаково. */
+21 -1
View File
@@ -42,6 +42,8 @@
'tables' => Model::tables(),
'stats' => Model::stats(),
'backups' => Backup::all(),
'backup_keep' => Backup::keep(),
'backup_keep_max' => Backup::KEEP_MAX,
'schema' => ModuleManager::coreSchemaStatus(),
'can_manage' => Permission::check('manage_database'),
]);
@@ -94,11 +96,29 @@
return $this->error('Не удалось создать бэкап: ' . $e->getMessage(), [], 500);
}
return $this->success('Бэкап создан: ' . $info['name'] . ' (' . $info['tables'] . ' таблиц)', [
$message = 'Бэкап создан: ' . $info['name'] . ' (' . $info['tables'] . ' таблиц)';
if (!empty($info['pruned'])) {
$message .= '. Удалено старых копий: ' . (int) $info['pruned'];
}
return $this->success($message, [
'redirect' => $this->base() . '/database?tab=backups',
]);
}
/** POST /database/backup/keep — сколько копий хранить. */
public function backupKeep(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$keep = Backup::setKeep(Request::postInt('keep', Backup::KEEP_DEFAULT));
$pruned = Backup::prune();
$message = $keep > 0
? 'Хранить последних копий: ' . $keep
: 'Ротация выключена, копии не удаляются';
if ($pruned['deleted'] > 0) { $message .= '. Удалено сейчас: ' . (int) $pruned['deleted']; }
return $this->success($message, array('redirect' => $this->base() . '/database?tab=backups'));
}
/** POST /database/backup/upload — загрузить и проверить внешний файл дампа. */
public function backupUpload(array $params = array())
{
@@ -314,3 +314,31 @@
font-size: 13px;
font-weight: 600;
}
/* --- Ротация резервных копий ------------------------------------------ */
.database-backup-keep {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 14px 16px;
}
.database-backup-keep-text {
flex: 1 1 260px;
min-width: 0;
}
.database-backup-keep-text p {
margin: 4px 0 0;
}
.database-backup-keep-field {
flex: 0 0 auto;
width: 150px;
margin: 0;
}
@media (max-width: 700px) {
.database-backup-keep {
align-items: stretch;
}
.database-backup-keep-field {
width: 100%;
}
}
@@ -42,6 +42,14 @@
if (e.target.closest('[data-db-restore-close]')) { self.closeRestoreProgress(); return; }
});
var keepForm = document.querySelector('[data-db-backup-keep-form]');
if (keepForm) {
keepForm.addEventListener('submit', function (e) {
e.preventDefault();
self.saveBackupKeep(keepForm);
});
}
document.addEventListener('input', function (e) {
var filter = e.target.closest('[data-db-table-filter]');
if (filter) { self.filterTables(filter.value); }
@@ -340,6 +348,15 @@
});
},
saveBackupKeep: function (form) {
if (!form.reportValidity()) { return; }
this.post(this.base() + '/database/backup/keep', new FormData(form)).then(function (d) {
if (d.success === false) { Adminx.Toast.show(d.message || 'Ошибка', 'error'); return; }
Adminx.Toast.show(d.message || 'Сохранено', 'success');
if (d.redirect) { window.location.href = d.redirect; } else { window.location.reload(); }
});
},
backup: function () {
var self = this;
Adminx.Confirm.open({
+2 -1
View File
@@ -21,7 +21,7 @@
return [
'code' => 'database',
'name' => 'База данных',
'version' => '0.1.5',
'version' => '0.2.0',
'permissions' => [
'key' => 'database',
@@ -79,6 +79,7 @@
array('GET', '/database', array(\App\Adminx\Database\Controller::class, 'index')),
array('POST', '/database/maintenance', array(\App\Adminx\Database\Controller::class, 'maintenance')),
array('POST', '/database/backup', array(\App\Adminx\Database\Controller::class, 'backupCreate')),
array('POST', '/database/backup/keep', array(\App\Adminx\Database\Controller::class, 'backupKeep'), array('permission' => 'manage_database')),
array('POST', '/database/backup/upload', array(\App\Adminx\Database\Controller::class, 'backupUpload'), array('permission' => 'manage_database')),
array('GET', '/database/backup/download', array(\App\Adminx\Database\Controller::class, 'backupDownload')),
array('POST', '/database/backup/delete', array(\App\Adminx\Database\Controller::class, 'backupDelete')),
+16
View File
@@ -123,6 +123,22 @@
<div><b>Восстановление полностью заменяет данные текущей схемы</b><p>Перед запуском AVE.cms автоматически создаст страховочную копию. Не закрывайте страницу до завершения операции.</p></div>
</div>
{% if can_manage %}
<form class="card database-backup-keep" data-db-backup-keep-form>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<span class="icon-tile" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-history-toggle"></i></span>
<div class="database-backup-keep-text">
<b>Сколько копий хранить</b>
<p class="text-secondary text-sm">После создания новой копии лишние старые удаляются. Загруженные вручную файлы не трогаются. Ноль отключает ротацию.</p>
</div>
<label class="field database-backup-keep-field">
<span class="field-label">Хранить последних</span>
<input class="input" type="number" name="keep" min="0" max="{{ backup_keep_max }}" value="{{ backup_keep }}">
</label>
<button class="btn btn-secondary" type="submit"><i class="ti ti-device-floppy"></i>Применить</button>
</form>
{% endif %}
<div class="card ax-list-card database-backups-card">
<div class="ax-list-head">
<div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--amber-100);--tile-fg:var(--amber-600)"><i class="ti ti-archive"></i></span><div><h2>Файлы резервных копий</h2><p class="text-secondary">Собственные и загруженные SQL-дампы базы данных.</p></div></div>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="directories_delete_confirm">Delete directory?</phrase>
<phrase data="directories_delete_value_confirm">Delete value?</phrase>
<phrase data="directories_edit">Directory settings</phrase>
<phrase data="directories_edit_value">Edit value</phrase>
<phrase data="directories_in_use">Directory is in use</phrase>
<phrase data="directories_new">New directory</phrase>
<phrase data="directories_new_value">New value</phrase>
<phrase data="directories_request_failed">Request failed</phrase>
<phrase data="directories_save_failed">Failed to save data</phrase>
</language>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="directories_title">Directories</phrase>
<phrase data="directories_description">Shared value lists that can be connected to multiple fields.</phrase>
<phrase data="directories_create">Create directory</phrase>
<phrase data="directories_shared">Shared directories</phrase>
<phrase data="directories_shared_description">Connect one list to multiple fields and maintain it centrally.</phrase>
<phrase data="directories_list">Directory list</phrase>
<phrase data="directories_storage_help">The key is stored in the document; the label can change without rewriting documents.</phrase>
<phrase data="directories_empty">No directories yet</phrase>
<phrase data="directories_empty_help">Create a shared value list for fields.</phrase>
<phrase data="directories_values_empty">No values yet</phrase>
<phrase data="directories_values_empty_help">Add the first key and label pair.</phrase>
<phrase data="directories_new">New directory</phrase>
<phrase data="directories_new_value">New value</phrase>
<phrase data="directories_source_help">Shared value source for fields.</phrase>
<phrase data="directories_stable_code_help">Stable technical code. It is generated from the name when left empty.</phrase>
<phrase data="directories_stable_key">Stable key</phrase>
<phrase data="directories_key_help">Stored in the document. Avoid changing the key after it is in use.</phrase>
<phrase data="directories_label_help">Editors and site visitors will see this label.</phrase>
<phrase data="directories_available">Available for field selection</phrase>
<phrase data="directories_show">Show in fields</phrase>
<phrase data="directories_open_values">Open values</phrase>
<phrase data="directories_add_value">Add value</phrase>
<phrase data="directories_edit">Directory settings</phrase>
<phrase data="directories_in_use">Directory is in use</phrase>
<phrase data="directories_delete_confirm">Delete directory?</phrase>
<phrase data="directories_edit_value">Edit value</phrase>
<phrase data="directories_delete_value_confirm">Delete value?</phrase>
<phrase data="directories_save_failed">Failed to save data</phrase>
<phrase data="directories_request_failed">Request failed</phrase>
</language>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="directories_delete_confirm">Удалить справочник?</phrase>
<phrase data="directories_delete_value_confirm">Удалить значение?</phrase>
<phrase data="directories_edit">Настройки справочника</phrase>
<phrase data="directories_edit_value">Изменить значение</phrase>
<phrase data="directories_in_use">Справочник используется</phrase>
<phrase data="directories_new">Новый справочник</phrase>
<phrase data="directories_new_value">Новое значение</phrase>
<phrase data="directories_request_failed">Ошибка запроса</phrase>
<phrase data="directories_save_failed">Не удалось сохранить данные</phrase>
</language>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="directories_title">Справочники</phrase>
<phrase data="directories_description">Общие списки значений, которые можно подключать к нескольким полям.</phrase>
<phrase data="directories_create">Создать справочник</phrase>
<phrase data="directories_shared">Общие справочники</phrase>
<phrase data="directories_shared_description">Один список можно подключить к нескольким полям и менять централизованно.</phrase>
<phrase data="directories_list">Список справочников</phrase>
<phrase data="directories_storage_help">Ключ хранится в документе, подпись можно менять без перезаписи документов.</phrase>
<phrase data="directories_empty">Справочников пока нет</phrase>
<phrase data="directories_empty_help">Создайте общий список значений для полей.</phrase>
<phrase data="directories_values_empty">Значений пока нет</phrase>
<phrase data="directories_values_empty_help">Добавьте первую пару «ключ → подпись».</phrase>
<phrase data="directories_new">Новый справочник</phrase>
<phrase data="directories_new_value">Новое значение</phrase>
<phrase data="directories_source_help">Общий источник значений для полей.</phrase>
<phrase data="directories_stable_code_help">Стабильный технический код. Если оставить пустым, сформируется из названия.</phrase>
<phrase data="directories_stable_key">Стабильный ключ</phrase>
<phrase data="directories_key_help">Хранится в документе. После начала использования ключ лучше не менять.</phrase>
<phrase data="directories_label_help">Эту подпись увидят редактор и посетитель сайта.</phrase>
<phrase data="directories_available">Доступен для выбора в полях</phrase>
<phrase data="directories_show">Показывать в полях</phrase>
<phrase data="directories_open_values">Открыть значения</phrase>
<phrase data="directories_add_value">Добавить значение</phrase>
<phrase data="directories_edit">Настройки справочника</phrase>
<phrase data="directories_in_use">Справочник используется</phrase>
<phrase data="directories_delete_confirm">Удалить справочник?</phrase>
<phrase data="directories_edit_value">Изменить значение</phrase>
<phrase data="directories_delete_value_confirm">Удалить значение?</phrase>
<phrase data="directories_save_failed">Не удалось сохранить данные</phrase>
<phrase data="directories_request_failed">Ошибка запроса</phrase>
</language>
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'directories',
'name' => 'Справочники',
'version' => '0.2.0',
'version' => '0.2.1',
'requires' => array('rubrics'),
'routes' => array(
+613
View File
@@ -0,0 +1,613 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Documents/BulkEditor.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Documents;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\AuditLog;
use App\Common\DatabaseSchema;
use App\Common\Lock;
use App\Content\CatalogTables;
use App\Content\ContentTables;
use App\Content\Documents\DocumentMutationService;
use App\Content\Documents\DocumentSearch;
use App\Helpers\File;
use DB;
/**
* Server-side plans for safe, previewable document batch mutations.
*
* A plan freezes matching IDs before execution. The browser only sends the
* opaque token back and cannot replace the document list after preview.
*/
class BulkEditor
{
const VERSION = 1;
const MAX_DOCUMENTS = 5000;
const CHUNK_SIZE = 20;
const EXPIRES_AFTER = 7200;
protected static $documentFields = array(
'document_title' => array(
'label' => 'Заголовок документа',
'payload' => 'title',
'help' => 'Основной заголовок документа в AVE.cms. У товара название на сайте может браться из отдельного поля рубрики.',
),
'document_excerpt' => array('label' => 'Тизер (анонс)', 'payload' => 'excerpt'),
'document_tags' => array('label' => 'Теги', 'payload' => 'tags'),
'document_meta_keywords' => array('label' => 'Meta keywords', 'payload' => 'meta_keywords'),
'document_meta_description' => array('label' => 'Meta description', 'payload' => 'meta_description'),
'document_property' => array('label' => 'Свойство / артикул', 'payload' => 'property'),
);
public static function options()
{
return array(
'rubrics' => Model::rubrics(),
'document_fields' => self::$documentFields,
'products_available' => self::productsAvailable(),
);
}
public static function preview(array $input, $actorId)
{
$filters = self::filters($input);
$operation = self::operation($input, $filters);
$matches = self::matches($filters);
if (!$matches['ids']) {
throw new \InvalidArgumentException('По выбранным условиям документы не найдены');
}
if ($matches['total'] > self::MAX_DOCUMENTS) {
throw new \InvalidArgumentException(
'Найдено ' . $matches['total'] . ' документов. Уточните фильтр: за один запуск можно обработать до ' . self::MAX_DOCUMENTS
);
}
$matchedTotal = (int) $matches['total'];
$changingRows = self::changingRows($matches['rows'], $operation);
if (!$changingRows) {
throw new \InvalidArgumentException('В найденных документах нет значений, которые изменит выбранное действие');
}
$sample = array();
foreach (array_slice($changingRows, 0, 20) as $row) {
$change = self::previewChange($row, $operation);
$sample[] = array(
'id' => (int) $row['Id'],
'title' => html_entity_decode((string) $row['document_title'], ENT_QUOTES, 'UTF-8'),
'rubric' => html_entity_decode((string) $row['rubric_title'], ENT_QUOTES, 'UTF-8'),
'state' => (int) $row['document_status'] === 1 ? 'Опубликован' : 'Черновик',
'before' => self::shortValue($change['before']),
'after' => self::shortValue($change['after']),
'changed' => $change['changed'],
'note' => $change['note'],
);
}
$token = bin2hex(random_bytes(20));
$plan = array(
'version' => self::VERSION,
'token' => $token,
'actor_id' => (int) $actorId,
'status' => 'prepared',
'created_at' => time(),
'expires_at' => time() + self::EXPIRES_AFTER,
'filters' => $filters,
'operation' => $operation,
'ids' => array_values(array_map(function ($row) { return (int) $row['Id']; }, $changingRows)),
'total' => count($changingRows),
'matched_total' => $matchedTotal,
'unchanged_total' => max(0, $matchedTotal - count($changingRows)),
'cursor' => 0,
'done' => 0,
'skipped' => 0,
'errors' => array(),
'sample' => $sample,
);
self::save($plan);
return self::publicPlan($plan);
}
public static function runChunk($token, $actorId)
{
return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) {
$plan = self::load($token, $actorId);
if (in_array($plan['status'], array('completed', 'cancelled'), true)) {
return self::publicPlan($plan);
}
$plan['status'] = 'running';
$service = new DocumentMutationService();
$end = min((int) $plan['total'], (int) $plan['cursor'] + self::CHUNK_SIZE);
while ((int) $plan['cursor'] < $end) {
$id = (int) $plan['ids'][$plan['cursor']];
try {
$result = self::apply($service, $id, $plan['operation'], (int) $actorId);
if ($result) { $plan['done']++; }
else { $plan['skipped']++; }
} catch (\Throwable $e) {
$plan['errors'][] = '#' . $id . ': ' . $e->getMessage();
if (count($plan['errors']) > 100) {
$plan['errors'] = array_slice($plan['errors'], -100);
}
}
$plan['cursor']++;
}
if ((int) $plan['cursor'] >= (int) $plan['total']) {
$plan['status'] = 'completed';
$plan['completed_at'] = time();
AuditLog::record('document.bulk_editor_completed', array(
'actor_id' => (int) $actorId,
'target_type' => 'document_batch',
'meta' => array(
'operation' => $plan['operation'],
'filters' => $plan['filters'],
'total' => $plan['total'],
'done' => $plan['done'],
'skipped' => $plan['skipped'],
'errors' => count($plan['errors']),
),
));
}
self::save($plan);
return self::publicPlan($plan);
}, 5.0, true);
}
public static function cancel($token, $actorId)
{
return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) {
$plan = self::load($token, $actorId);
if ($plan['status'] !== 'completed') {
$plan['status'] = 'cancelled';
$plan['cancelled_at'] = time();
self::save($plan);
}
return self::publicPlan($plan);
}, 5.0, true);
}
public static function status($token, $actorId)
{
return self::publicPlan(self::load($token, $actorId));
}
protected static function apply(DocumentMutationService $service, $documentId, array $operation, $actorId)
{
$row = DB::query(
"SELECT * FROM " . ContentTables::table('documents') . " WHERE Id=%i AND document_deleted='0' LIMIT 1",
(int) $documentId
)->getAssoc();
if (!$row) { return false; }
$type = $operation['type'];
if ($type === 'publish' || $type === 'unpublish') {
if (Model::isProtectedDocument($documentId)) { return false; }
$status = $type === 'publish' ? 1 : 0;
if ((int) $row['document_status'] === $status) { return false; }
$service->save($documentId, array('status' => $status), $actorId, 'bulk_editor');
return true;
}
if ($type === 'recalculate') {
$service->save($documentId, array('fields' => array()), $actorId, 'bulk_editor');
return true;
}
if ($type === 'move') {
if (Model::isProtectedDocument($documentId)) { return false; }
$result = $service->move($documentId, $operation['target_rubric_id'], $actorId, 'bulk_editor');
return !empty($result['moved']);
}
$current = self::currentValue($row, $operation);
$next = self::changedValue($current, $operation);
if (!$next['changed']) { return false; }
if ($operation['target_type'] === 'document') {
$payloadKey = self::$documentFields[$operation['target']]['payload'];
$service->save($documentId, array($payloadKey => $next['value']), $actorId, 'bulk_editor');
} else {
$service->save($documentId, array('fields' => array($operation['field_id'] => $next['value'])), $actorId, 'bulk_editor');
}
return true;
}
protected static function filters(array $input)
{
$scope = isset($input['scope']) ? (string) $input['scope'] : 'all';
if (!in_array($scope, array('all', 'documents', 'products'), true) || ($scope !== 'all' && !self::productsAvailable())) {
$scope = 'all';
}
$state = isset($input['state']) ? (string) $input['state'] : '';
if (!in_array($state, array('', 'active', 'draft'), true)) { $state = ''; }
return array(
'q' => trim(isset($input['q']) ? (string) $input['q'] : ''),
'rubric_id' => max(0, isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0),
'state' => $state,
'scope' => $scope,
);
}
protected static function operation(array $input, array $filters)
{
$type = isset($input['operation']) ? (string) $input['operation'] : '';
$allowed = array('fill', 'set', 'clear', 'replace', 'move', 'publish', 'unpublish', 'recalculate');
if (!in_array($type, $allowed, true)) {
throw new \InvalidArgumentException('Выберите действие');
}
$operation = array('type' => $type, 'label' => self::operationLabel($type));
if (in_array($type, array('move', 'publish', 'unpublish', 'recalculate'), true)) {
if ($type === 'move') {
$targetRubricId = isset($input['target_rubric_id']) ? (int) $input['target_rubric_id'] : 0;
if ($targetRubricId <= 0 || !Model::rubric($targetRubricId)) {
throw new \InvalidArgumentException('Выберите целевую рубрику');
}
if ($filters['rubric_id'] > 0 && $targetRubricId === $filters['rubric_id']) {
throw new \InvalidArgumentException('Исходная и целевая рубрики совпадают');
}
$operation['target_rubric_id'] = $targetRubricId;
$operation['target_rubric_title'] = Model::rubric($targetRubricId)['rubric_title'];
}
return $operation;
}
$target = isset($input['target']) ? trim((string) $input['target']) : '';
if (isset(self::$documentFields[$target])) {
$operation['target_type'] = 'document';
$operation['target'] = $target;
$operation['target_label'] = self::$documentFields[$target]['label'];
} elseif (strpos($target, 'field:') === 0) {
$fieldId = (int) substr($target, 6);
if ($filters['rubric_id'] <= 0) {
throw new \InvalidArgumentException('Для изменения поля сначала выберите одну рубрику');
}
$field = self::field($fieldId, $filters['rubric_id']);
if (!$field) { throw new \InvalidArgumentException('Поле не принадлежит выбранной рубрике'); }
$operation['target_type'] = 'field';
$operation['target'] = $target;
$operation['field_id'] = $fieldId;
$operation['target_label'] = html_entity_decode((string) $field['rubric_field_title'], ENT_QUOTES, 'UTF-8');
} else {
throw new \InvalidArgumentException('Выберите поле для изменения');
}
$operation['value'] = isset($input['value']) ? (string) $input['value'] : '';
$operation['search'] = isset($input['search']) ? (string) $input['search'] : '';
if ($type === 'replace' && $operation['search'] === '') {
throw new \InvalidArgumentException('Укажите текст, который нужно заменить');
}
return $operation;
}
protected static function matches(array $filters)
{
$where = " WHERE d.document_deleted='0'";
$args = array();
if ($filters['rubric_id'] > 0) {
$where .= ' AND d.rubric_id=%i';
$args[] = $filters['rubric_id'];
}
if ($filters['state'] === 'active') { $where .= " AND d.document_status='1'"; }
elseif ($filters['state'] === 'draft') { $where .= " AND d.document_status='0'"; }
$productTable = CatalogTables::table('catalog_product_index');
if (self::productsAvailable() && $filters['scope'] === 'products') {
$where .= ' AND EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)';
} elseif (self::productsAvailable() && $filters['scope'] === 'documents') {
$where .= ' AND NOT EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)';
}
$search = DocumentSearch::criteria($filters['q'], 'd');
if ($search['where'] !== '') {
$where .= ' AND ' . $search['where'];
$args = array_merge($args, $search['where_args']);
}
$sql = 'SELECT d.Id,d.document_title,d.document_status,d.rubric_id,r.rubric_title'
. ' FROM ' . ContentTables::table('documents') . ' d'
. ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id'
. $where . ' ORDER BY d.Id ASC LIMIT ' . (self::MAX_DOCUMENTS + 1);
$rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array();
$ids = array();
foreach ($rows as $row) { $ids[] = (int) $row['Id']; }
return array('ids' => $ids, 'rows' => $rows, 'total' => count($rows));
}
protected static function changingRows(array $rows, array $operation)
{
if (!$rows || $operation['type'] === 'recalculate') { return $rows; }
$currentValues = array();
$ids = array();
foreach ($rows as $row) { $ids[] = (int) $row['Id']; }
if (isset($operation['target_type']) && $operation['target_type'] === 'document') {
$column = (string) $operation['target'];
if (!isset(self::$documentFields[$column])) {
throw new \InvalidArgumentException('Поле документа недоступно для массового изменения');
}
$valueRows = DB::query(
'SELECT Id,`' . $column . '` current_value FROM ' . ContentTables::table('documents') . ' WHERE Id IN %li',
$ids
)->getAll();
foreach ($valueRows ?: array() as $valueRow) {
$currentValues[(int) $valueRow['Id']] = html_entity_decode((string) $valueRow['current_value'], ENT_QUOTES, 'UTF-8');
}
} elseif (isset($operation['target_type']) && $operation['target_type'] === 'field') {
$valueRows = DB::query(
'SELECT df.document_id,CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\')) current_value'
. ' FROM ' . ContentTables::table('document_fields') . ' df'
. ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft'
. ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id'
. ' WHERE df.document_id IN %li AND df.rubric_field_id=%i',
$ids,
(int) $operation['field_id']
)->getAll();
foreach ($valueRows ?: array() as $valueRow) {
$currentValues[(int) $valueRow['document_id']] = (string) $valueRow['current_value'];
}
}
$changing = array();
foreach ($rows as $row) {
if (isset($operation['target_type'])) {
$row['__bulk_current'] = isset($currentValues[(int) $row['Id']])
? $currentValues[(int) $row['Id']]
: '';
}
if (self::previewChange($row, $operation)['changed']) { $changing[] = $row; }
}
return $changing;
}
protected static function previewChange(array $row, array $operation)
{
if ($operation['type'] === 'publish') {
return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Опубликован', 'changed' => !(int) $row['document_status'], 'note' => '');
}
if ($operation['type'] === 'unpublish') {
return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Черновик', 'changed' => (bool) $row['document_status'], 'note' => '');
}
if ($operation['type'] === 'recalculate') {
return array('before' => 'Текущие значения', 'after' => 'Пересчитать поля и индексы', 'changed' => true, 'note' => '');
}
if ($operation['type'] === 'move') {
return array('before' => (string) $row['rubric_title'], 'after' => (string) $operation['target_rubric_title'], 'changed' => (int) $row['rubric_id'] !== (int) $operation['target_rubric_id'], 'note' => 'Совпадающие поля переносятся по системному имени');
}
$current = self::currentValue($row, $operation);
$next = self::changedValue($current, $operation);
return array('before' => $current, 'after' => $next['value'], 'changed' => $next['changed'], 'note' => $next['changed'] ? '' : 'Значение уже соответствует действию');
}
protected static function currentValue(array $row, array $operation)
{
if (array_key_exists('__bulk_current', $row)) {
return (string) $row['__bulk_current'];
}
if ($operation['target_type'] === 'document') {
if (!array_key_exists($operation['target'], $row)) {
$value = DB::query(
'SELECT `' . $operation['target'] . '` FROM ' . ContentTables::table('documents') . ' WHERE Id=%i',
(int) $row['Id']
)->getValue();
return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8');
}
return html_entity_decode((string) $row[$operation['target']], ENT_QUOTES, 'UTF-8');
}
return (string) DB::query(
'SELECT CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\'))'
. ' FROM ' . ContentTables::table('document_fields') . ' df'
. ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft'
. ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id'
. ' WHERE df.document_id=%i AND df.rubric_field_id=%i LIMIT 1',
(int) $row['Id'],
(int) $operation['field_id']
)->getValue();
}
protected static function changedValue($current, array $operation)
{
$current = (string) $current;
$type = $operation['type'];
if ($type === 'fill') {
$value = trim($current) === '' ? (string) $operation['value'] : $current;
} elseif ($type === 'clear') {
$value = '';
} elseif ($type === 'replace') {
$value = str_replace((string) $operation['search'], (string) $operation['value'], $current);
} else {
$value = (string) $operation['value'];
}
return array('value' => $value, 'changed' => $value !== $current);
}
protected static function field($fieldId, $rubricId)
{
return DB::query(
'SELECT Id,rubric_id,rubric_field_title,rubric_field_alias,rubric_field_type'
. ' FROM ' . ContentTables::table('rubric_fields') . ' WHERE Id=%i AND rubric_id=%i LIMIT 1',
(int) $fieldId,
(int) $rubricId
)->getAssoc() ?: null;
}
public static function fields($rubricId)
{
$commerce = self::commerceFieldUsage((int) $rubricId);
$rows = DB::query(
'SELECT Id,rubric_field_title,rubric_field_alias,rubric_field_type'
. ' FROM ' . ContentTables::table('rubric_fields')
. ' WHERE rubric_id=%i ORDER BY rubric_field_position ASC,Id ASC',
(int) $rubricId
)->getAll();
$out = array();
foreach ($rows ?: array() as $row) {
$fieldId = (int) $row['Id'];
$usage = isset($commerce[$fieldId]) ? $commerce[$fieldId] : '';
$out[] = array(
'id' => $fieldId,
'title' => html_entity_decode((string) $row['rubric_field_title'], ENT_QUOTES, 'UTF-8'),
'alias' => (string) $row['rubric_field_alias'],
'type' => (string) $row['rubric_field_type'],
'usage' => $usage,
'help' => $usage !== '' ? 'Это поле используется товарным представлением как ' . $usage . '. Изменение обновит витрину и товарный индекс.' : '',
);
}
return $out;
}
protected static function commerceFieldUsage($rubricId)
{
if ($rubricId <= 0 || !self::productsAvailable()) { return array(); }
$row = DB::query(
'SELECT product_title_field_id,product_article_field_id,product_price_field_id,'
. 'product_old_price_field_id,product_stock_field_id,product_images_field_id'
. ' FROM ' . CatalogTables::table('module_catalog_settings')
. " WHERE rubric_id=%i AND purpose='commerce' ORDER BY id ASC LIMIT 1",
(int) $rubricId
)->getAssoc();
if (!$row) { return array(); }
$labels = array(
'product_title_field_id' => 'название товара на сайте',
'product_article_field_id' => 'артикул товара',
'product_price_field_id' => 'текущая цена',
'product_old_price_field_id' => 'старая цена',
'product_stock_field_id' => 'остаток / наличие',
'product_images_field_id' => 'изображения товара',
);
$usage = array();
foreach ($labels as $column => $label) {
$fieldId = isset($row[$column]) ? (int) $row[$column] : 0;
if ($fieldId > 0) { $usage[$fieldId] = $label; }
}
return $usage;
}
protected static function productsAvailable()
{
try {
return DatabaseSchema::tableExists(CatalogTables::table('catalog_product_index'));
} catch (\Throwable $e) {
return false;
}
}
protected static function operationLabel($type)
{
$labels = array(
'fill' => 'Заполнить пустые', 'set' => 'Установить значение', 'clear' => 'Очистить',
'replace' => 'Найти и заменить', 'move' => 'Перенести в рубрику',
'publish' => 'Опубликовать', 'unpublish' => 'Снять с публикации',
'recalculate' => 'Пересчитать поля и индексы',
);
return isset($labels[$type]) ? $labels[$type] : $type;
}
protected static function shortValue($value)
{
$value = trim(preg_replace('/\s+/u', ' ', strip_tags((string) $value)));
if ($value === '') { return 'Пусто'; }
return mb_strlen($value, 'UTF-8') > 180 ? mb_substr($value, 0, 177, 'UTF-8') . '...' : $value;
}
protected static function publicPlan(array $plan)
{
$total = max(1, (int) $plan['total']);
return array(
'token' => $plan['token'],
'status' => $plan['status'],
'total' => (int) $plan['total'],
'matched_total' => isset($plan['matched_total']) ? (int) $plan['matched_total'] : (int) $plan['total'],
'unchanged_total' => isset($plan['unchanged_total']) ? (int) $plan['unchanged_total'] : 0,
'processed' => (int) $plan['cursor'],
'done' => (int) $plan['done'],
'skipped' => (int) $plan['skipped'],
'errors' => $plan['errors'],
'progress' => (int) floor(((int) $plan['cursor'] / $total) * 100),
'operation' => $plan['operation'],
'filters' => $plan['filters'],
'sample' => $plan['sample'],
);
}
protected static function load($token, $actorId)
{
if (!preg_match('/^[a-f0-9]{40}$/', (string) $token)) {
throw new \InvalidArgumentException('Некорректный идентификатор плана');
}
$path = self::path($token);
$data = is_file($path) ? json_decode((string) File::getContent($path), true) : null;
if (!is_array($data) || (int) $data['version'] !== self::VERSION || !hash_equals((string) $data['token'], (string) $token)) {
throw new \RuntimeException('План массового изменения не найден');
}
if ((int) $data['actor_id'] !== (int) $actorId) {
throw new \RuntimeException('План создан другим пользователем');
}
if ((int) $data['expires_at'] < time() && !in_array($data['status'], array('completed', 'cancelled'), true)) {
throw new \RuntimeException('Срок действия предпросмотра истёк. Выполните проверку ещё раз');
}
return $data;
}
protected static function save(array $plan)
{
$directory = dirname(self::path($plan['token']));
if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new \RuntimeException('Не удалось создать каталог заданий');
}
$raw = json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
if (!File::putAtomic(self::path($plan['token']), $raw, 0640)) {
throw new \RuntimeException('Не удалось сохранить план массового изменения');
}
}
protected static function path($token)
{
return rtrim(BASEPATH, '/\\') . '/storage/jobs/document-bulk/' . (string) $token . '.json';
}
}
+106 -2
View File
@@ -25,6 +25,7 @@
use App\Common\ErrorReport;
use App\Common\Permission;
use App\Adminx\Support\CodeEditor;
use App\Adminx\Support\SavedViews;
use App\Adminx\Rubrics\AdminView;
use App\Content\Documents\DocumentHookException;
use App\Content\Documents\ContentCacheInvalidator;
@@ -44,6 +45,9 @@
class Controller extends BaseController
{
protected function savedViewFields() { return array('q', 'field', 'rubric_id', 'state', 'per_page'); }
protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_documents') ? null : $this->error('Недостаточно прав', array(), 403); }
public function index(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50);
@@ -84,9 +88,28 @@
'table_name' => Model::documentsTable(),
'can_manage' => Permission::check('manage_documents'),
'open_create' => Request::getBool('create', false),
'saved_views' => SavedViews::all('documents', Auth::id(), $this->savedViewFields()),
));
}
public function saveSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
$filters = json_decode(Request::postStr('filters', '{}'), true);
if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); }
try { $views = SavedViews::save('documents', Auth::id(), Request::postStr('title', ''), $filters, $this->savedViewFields()); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('Представление сохранено', array('data' => array('views' => $views)));
}
public function deleteSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
try { $views = SavedViews::delete('documents', Auth::id(), isset($params['id']) ? $params['id'] : '', $this->savedViewFields()); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); }
return $this->success('Представление удалено', array('data' => array('views' => $views)));
}
public function create(array $params = array())
{
if (!Permission::check('manage_documents')) {
@@ -190,7 +213,13 @@
try {
$scopes = Request::post('scopes', array());
$scopes = is_array($scopes) ? $scopes : array();
$result = ApiTokenRepository::issue(Auth::id(), Request::postStr('name', ''), $scopes, Request::postStr('expires_at', ''));
$result = ApiTokenRepository::issueRestricted(
Auth::id(),
Request::postStr('name', ''),
$scopes,
array('documents:read', 'documents:write', 'documents:*'),
Request::postStr('expires_at', '')
);
AuditLog::record('document.api_token_created', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$result['id'],'meta'=>array('scopes'=>$result['scopes'])));
return $this->success('API-токен создан', array('data' => $result));
} catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
@@ -201,7 +230,10 @@
if (($err = $this->csrfGuard()) !== null) { return $err; }
if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); }
$id = isset($params['id']) ? (int) $params['id'] : 0;
if (!ApiTokenRepository::revoke($id)) { return $this->error('Токен не найден или уже отозван', array(), 404); }
if (!ApiTokenRepository::revokeRestricted($id, array('documents:read', 'documents:write'))) {
return $this->error('Токен документов не найден или уже отозван', array(), 404);
}
AuditLog::record('document.api_token_revoked', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$id));
return $this->success('API-токен отозван');
}
@@ -391,6 +423,70 @@
return $this->success('Пакетное действие выполнено', array('data' => $result));
}
public function bulkEditor(array $params = array())
{
if (!Permission::check('manage_documents')) {
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
}
AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50);
AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50);
return $this->render('@documents/bulk-editor.twig', array(
'bulk_options' => BulkEditor::options(),
));
}
public function bulkEditorFields(array $params = array())
{
if (!Permission::check('manage_documents')) {
return $this->error('Недостаточно прав', array(), 403);
}
return $this->success('', array('data' => array(
'items' => BulkEditor::fields(Request::getInt('rubric_id', 0)),
)));
}
public function bulkEditorPreview(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
try {
$plan = BulkEditor::preview(Request::postAll(), Auth::id());
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), array(), 422);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage('Не удалось подготовить массовое изменение', $e, 'DOCBULKPREVIEW'), array(), 500);
}
return $this->success('Предпросмотр подготовлен', array('data' => array('plan' => $plan)));
}
public function bulkEditorRun(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
try {
$plan = BulkEditor::runChunk(Request::postStr('token', ''), Auth::id());
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), array(), 422);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage('Не удалось выполнить пакет документов', $e, 'DOCBULKRUN'), array(), 500);
}
return $this->success($plan['status'] === 'completed' ? 'Массовое изменение завершено' : 'Пакет обработан', array('data' => array('plan' => $plan)));
}
public function bulkEditorCancel(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
try {
$plan = BulkEditor::cancel(Request::postStr('token', ''), Auth::id());
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
return $this->success('Выполнение остановлено', array('data' => array('plan' => $plan)));
}
public function store(array $params = array())
{
if (($err = $this->guard()) !== null) {
@@ -559,6 +655,12 @@
return $this->error('Проверьте поля документа', $errors, 422);
}
$mediaReplacement = DocumentMediaReplacement::capture(
$fieldGroups,
$fieldValues,
DocumentMediaReplacement::requestedFieldIds(Request::post('media_replace_fields', array()))
);
$mediaFinalization = null;
$previousDatabaseExceptionMode = DB::$throw_exception_on_error;
DB::$throw_exception_on_error = true;
@@ -622,10 +724,12 @@
$snapshotError = ContentCacheInvalidator::consumeError($id);
$snapshot = (new DocumentSnapshotRepository())->find($id);
DocumentSaveEvents::after('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc, is_array($snapshot) ? $snapshot : array());
$mediaCleanup = DocumentMediaReplacement::cleanup($mediaReplacement, $fieldValues);
return $this->success('Документ сохранён', array('data' => array(
'id' => $id,
'document_version' => Model::documentVersion($id),
'snapshot_warning' => $snapshotError,
'media_cleanup' => $mediaCleanup,
'media_draft_token' => DocumentMediaDraft::issue(Auth::id(), $id),
), 'redirect' => $this->base() . '/documents/' . $id . '/edit'));
}
@@ -0,0 +1,126 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Documents/DocumentMediaReplacement.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Documents;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Adminx\Media\MediaAudit;
use App\Adminx\Media\Model as MediaModel;
use App\Content\Fields\DocumentMediaFieldType;
use App\Content\Fields\FieldRegistry;
/** Removes superseded document media only after the document transaction succeeds. */
class DocumentMediaReplacement
{
public static function requestedFieldIds($value)
{
$value = is_array($value) ? $value : array($value);
$ids = array();
foreach ($value as $id) {
$id = (int) $id;
if ($id > 0) { $ids[$id] = $id; }
}
return array_values($ids);
}
/** Capture trusted old paths before field values are saved. */
public static function capture(array $fieldGroups, array $newValues, array $requestedFieldIds)
{
$requested = array_fill_keys(self::requestedFieldIds($requestedFieldIds), true);
if (!$requested) { return array(); }
$captured = array();
foreach ($fieldGroups as $group) {
foreach (isset($group['items']) && is_array($group['items']) ? $group['items'] : array() as $field) {
$fieldId = isset($field['Id']) ? (int) $field['Id'] : 0;
if (!isset($requested[$fieldId]) || !array_key_exists($fieldId, $newValues)) { continue; }
$typeCode = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : '';
$type = FieldRegistry::get($typeCode);
if (!$type instanceof DocumentMediaFieldType) { continue; }
$oldValue = isset($field['parsed']) ? $field['parsed'] : array();
$captured[$fieldId] = array(
'type' => $typeCode,
'paths' => self::uploadPaths($type->mediaPaths($oldValue)),
);
}
}
return $captured;
}
public static function removedPaths(array $captured, array $newValues)
{
$removed = array();
foreach ($captured as $fieldId => $field) {
$type = FieldRegistry::get(isset($field['type']) ? (string) $field['type'] : '');
if (!$type instanceof DocumentMediaFieldType) { continue; }
$newValue = array_key_exists($fieldId, $newValues) ? $newValues[$fieldId] : array();
$newPaths = array_fill_keys(self::uploadPaths($type->mediaPaths($newValue)), true);
foreach (isset($field['paths']) && is_array($field['paths']) ? $field['paths'] : array() as $path) {
if (!isset($newPaths[$path])) { $removed[$path] = $path; }
}
}
return array_values($removed);
}
/** Move currently unused files to the recoverable media trash. */
public static function cleanup(array $captured, array $newValues)
{
$paths = self::removedPaths($captured, $newValues);
$result = array('checked' => count($paths), 'trashed' => 0, 'kept' => 0, 'errors' => array());
if (!$paths) { return $result; }
try {
$usage = MediaAudit::inspectUsageMany($paths, 1, false);
} catch (\Throwable $e) {
$result['errors'][] = $e->getMessage();
return $result;
}
foreach ($paths as $path) {
if (!isset($usage[$path])) { continue; }
if (!empty($usage[$path]['use_count'])) {
$result['kept']++;
continue;
}
try {
MediaModel::trash($path);
$result['trashed']++;
} catch (\Throwable $e) {
$result['errors'][] = basename($path) . ': ' . $e->getMessage();
}
}
return $result;
}
protected static function uploadPaths(array $paths)
{
$result = array();
foreach ($paths as $path) {
$path = html_entity_decode(trim((string) $path), ENT_QUOTES, 'UTF-8');
$parsed = parse_url($path, PHP_URL_PATH);
$path = is_string($parsed) ? rawurldecode($parsed) : $path;
$path = '/' . ltrim(preg_replace('#/+#', '/', str_replace('\\', '/', $path)), '/');
if (strpos($path, '/uploads/') === 0 && strpos($path, '/../') === false) { $result[$path] = $path; }
}
return array_values($result);
}
}
+5 -8
View File
@@ -22,6 +22,7 @@
use App\Content\Fields\FieldConditionEvaluator;
use App\Content\Fields\FieldValueCodec;
use App\Content\Fields\FieldContext;
use App\Content\Fields\HtmlSanitizer;
use App\Content\Fields\FieldRegistry;
use App\Content\Fields\FieldLifecycle;
use App\Content\Fields\FieldSettings;
@@ -1680,6 +1681,7 @@
$editor = FieldAdminEditors::describe($type);
$kind = self::documentEditorKind($editor);
$settings = FieldSettings::effective($row);
$description = self::decode($row['rubric_field_description']);
if ($type === 'choice') {
$kind = isset($settings['mode']) && (string) $settings['mode'] === 'multiple' ? 'choice_multi' : 'choice';
}
@@ -1697,7 +1699,8 @@
'rubric_field_type' => $type,
'rubric_field_numeric' => (int) $row['rubric_field_numeric'],
'rubric_field_search' => (int) $row['rubric_field_search'],
'rubric_field_description' => self::decode($row['rubric_field_description']),
'rubric_field_description' => $description,
'rubric_field_description_html' => HtmlSanitizer::clean($description),
'rubric_field_default' => (string) $row['rubric_field_default'],
'rubric_field_settings' => isset($row['rubric_field_settings']) ? (string) $row['rubric_field_settings'] : '',
'group_settings' => isset($row['group_settings']) ? (string) $row['group_settings'] : '',
@@ -2125,7 +2128,7 @@
$data = array(
'field_value' => $first,
'field_number_value' => ((int) $field['rubric_field_numeric'] === 1)
? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : self::numericValue($value))
? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : FieldValueCodec::numericIndexValue($value))
: 0,
'document_in_search' => (int) $inSearch ? '1' : '0',
);
@@ -2579,12 +2582,6 @@
return in_array($value, array('index,follow', 'index,nofollow', 'noindex,nofollow'), true) ? $value : 'index,follow';
}
protected static function numericValue($value)
{
$value = preg_replace('/[^\d.]/', '', (string) $value);
return $value === '' ? 0 : $value;
}
protected static function dateInput($time)
{
return (int) $time > 0 ? date('Y-m-d\\TH:i', (int) $time) : '';
+25 -2
View File
@@ -19,6 +19,7 @@
use DB;
use App\Common\SystemTables;
use App\Content\Documents\DocumentRevisionPayload;
use App\Content\Revisions\JsonRevisionStore;
class Revisions
{
@@ -168,6 +169,27 @@
$payload = self::decodePayload((string) $row['doc_data']);
$values = $withData ? $payload['fields'] : null;
$document = $withData ? $payload['document'] : null;
$documentPreview = $withData ? self::documentPreview($payload['document']) : array();
$fieldPreview = $withData && is_array($values) ? self::preview($values) : array();
$comparison = array('document' => array(), 'fields' => array());
if ($withData) {
$currentDocument = Model::one((int) $row['doc_id']);
$currentFields = Model::currentFieldValues((int) $row['doc_id']);
$comparison['document'] = JsonRevisionStore::compareSnapshots($currentDocument ?: array(), $payload['document']);
$comparison['fields'] = JsonRevisionStore::compareSnapshots($currentFields, $payload['fields']);
foreach ($documentPreview as &$item) {
$item['changed'] = !empty($comparison['document']['items'][$item['key']]['changed']);
}
unset($item);
foreach ($fieldPreview as &$item) {
$key = (string) $item['field_id'];
$item['changed'] = !empty($comparison['fields']['items'][$key]['changed']);
}
unset($item);
}
return array(
'id' => (int) $row['Id'],
'doc_id' => (int) $row['doc_id'],
@@ -181,8 +203,9 @@
'size_label' => self::formatBytes(strlen((string) $row['doc_data'])),
'values' => $values,
'document' => $document,
'document_preview' => $withData ? self::documentPreview($payload['document']) : array(),
'preview' => $withData && is_array($values) ? self::preview($values) : array(),
'document_preview' => $documentPreview,
'preview' => $fieldPreview,
'comparison' => $comparison,
);
}
@@ -1168,6 +1168,244 @@
width: 100%;
}
}
.documents-bulk-editor {
display: grid;
gap: 14px;
}
.documents-bulk-notice {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0;
}
.documents-bulk-notice > i {
margin-top: 2px;
font-size: 20px;
}
.documents-bulk-notice b,
.documents-bulk-notice span {
display: block;
}
.documents-bulk-notice span {
margin-top: 2px;
font-size: 12px;
}
.documents-bulk-form {
display: grid;
gap: 14px;
}
.documents-bulk-step,
.documents-bulk-preview,
.documents-bulk-progress {
padding: 0;
overflow: hidden;
}
.documents-bulk-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 16px;
}
.documents-bulk-controls .field {
min-width: 0;
}
.documents-bulk-search,
.documents-bulk-value {
grid-column: span 2;
}
.documents-bulk-value .textarea {
min-height: 86px;
resize: vertical;
}
.documents-bulk-target-help {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 16px 16px;
padding: 10px 12px;
border-radius: 6px;
background: var(--blue-50);
color: var(--blue-700);
font-size: 12px;
line-height: 1.45;
}
.documents-bulk-target-help[hidden] {
display: none;
}
.documents-bulk-target-help > i {
margin-top: 1px;
font-size: 16px;
}
.documents-bulk-step-footer,
.documents-bulk-run {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 62px;
padding: 10px 16px;
border-top: 1px solid var(--border-default);
background: var(--background-muted);
}
.documents-bulk-step-footer > span {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
}
.documents-bulk-table {
table-layout: fixed;
}
.documents-bulk-table th:nth-child(1) {
width: 24%;
}
.documents-bulk-table th:nth-child(2) {
width: 16%;
}
.documents-bulk-table th:nth-child(3),
.documents-bulk-table th:nth-child(4) {
width: 23%;
}
.documents-bulk-table th:nth-child(5) {
width: 14%;
}
.documents-bulk-table td {
vertical-align: top;
overflow-wrap: anywhere;
}
.documents-bulk-table td b,
.documents-bulk-table td small {
display: block;
}
.documents-bulk-table td small {
margin-top: 3px;
color: var(--text-muted);
font-size: 11px;
}
.documents-bulk-value-cell {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.documents-bulk-result {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.documents-bulk-result.is-changed {
color: var(--color-success);
}
.documents-bulk-run > div b,
.documents-bulk-run > div span {
display: block;
}
.documents-bulk-run > div span {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
}
.documents-bulk-progress {
padding: 18px;
}
.documents-bulk-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.documents-bulk-progress-head b,
.documents-bulk-progress-head span {
display: block;
}
.documents-bulk-progress-head span {
margin-top: 3px;
color: var(--text-muted);
font-size: 12px;
}
.documents-bulk-progress-head strong {
font-size: 22px;
line-height: 1;
}
.documents-bulk-progress .progress {
height: 8px;
overflow: hidden;
border-radius: 4px;
background: var(--background-muted);
}
.documents-bulk-progress .progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--blue-600);
transition: width 180ms ease;
}
.documents-bulk-progress-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
}
.documents-bulk-progress-stats span {
padding: 9px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 11px;
}
.documents-bulk-progress-stats b {
display: block;
margin-bottom: 2px;
color: var(--text-primary);
font-size: 17px;
}
.documents-bulk-progress-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.documents-bulk-errors {
display: grid;
gap: 4px;
max-height: 180px;
margin-top: 12px;
padding: 10px;
overflow: auto;
border-radius: var(--radius-sm);
background: var(--color-danger-soft);
color: var(--color-danger);
font-size: 11px;
}
@media (max-width: 900px) {
.documents-bulk-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.documents-bulk-progress-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.documents-bulk-controls {
grid-template-columns: 1fr;
}
.documents-bulk-search,
.documents-bulk-value {
grid-column: auto;
}
.documents-bulk-step-footer,
.documents-bulk-run {
align-items: stretch;
flex-direction: column;
}
.documents-bulk-step-footer .btn,
.documents-bulk-run .btn {
width: 100%;
}
.documents-bulk-progress-stats {
grid-template-columns: 1fr 1fr;
}
}
.documents-views-panel {
display: grid;
gap: 16px;
@@ -1814,6 +2052,27 @@
font-size: 12px;
line-height: 1.45;
}
.documents-field-description > :first-child {
margin-top: 0;
}
.documents-field-description > :last-child {
margin-bottom: 0;
}
.documents-field-description p {
margin: 0 0 6px;
}
.documents-field-description ul,
.documents-field-description ol {
margin: 4px 0 6px;
padding-left: 20px;
}
.documents-field-description strong,
.documents-field-description b {
color: var(--text-secondary);
}
.documents-field-description a {
color: var(--color-primary);
}
.documents-catalog-field {
display: grid;
gap: 10px;
@@ -2258,6 +2517,22 @@
gap: 8px;
flex-wrap: wrap;
}
.documents-media-replacement-note {
display: flex;
align-items: center;
gap: 7px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.documents-media-replacement-note[hidden] {
display: none;
}
.documents-media-replacement-note i {
flex: 0 0 auto;
color: var(--amber-600);
font-size: 16px;
}
.documents-media-list-actions input[type="file"],
.visually-hidden {
position: absolute;
@@ -2804,6 +3079,12 @@
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
}
.documents-revision-field.is-unchanged {
opacity: 0.64;
}
.documents-revision-field.is-changed {
border-color: var(--amber-300);
}
.documents-revision-field div {
display: flex;
align-items: center;
+341 -14
View File
@@ -96,6 +96,12 @@
var mediaClear = e.target.closest('[data-document-media-clear]');
if (mediaClear) { self.clearMediaRows(mediaClear.closest('[data-document-media-list]')); }
var mediaClearSingle = e.target.closest('[data-document-media-clear-single]');
if (mediaClearSingle) { self.clearSingleMedia(mediaClearSingle.closest('[data-document-media-single]')); }
var mediaReplace = e.target.closest('[data-document-media-replace]');
if (mediaReplace) { self.openMediaReplacement(mediaReplace.closest('[data-document-media-list], [data-document-media-single]')); }
var mediaUpload = e.target.closest('[data-document-media-upload]');
if (mediaUpload) { self.openMediaUpload(mediaUpload.closest('[data-document-media-list], [data-document-media-single]')); }
@@ -352,7 +358,9 @@
document.addEventListener('change', function (e) {
if (!e.target.matches('[data-document-media-files]')) { return; }
self.uploadMediaFiles(e.target.closest('[data-document-media-list], [data-document-media-single]'), e.target.files);
var replace = e.target.getAttribute('data-replace-upload') === '1';
e.target.removeAttribute('data-replace-upload');
self.uploadMediaFiles(e.target.closest('[data-document-media-list], [data-document-media-single]'), e.target.files, replace);
e.target.value = '';
});
@@ -1378,6 +1386,9 @@
if (payload.data && payload.data.snapshot_warning) {
Adminx.Toast.show('Документ сохранён, но JSON-снимок не записан: ' + payload.data.snapshot_warning, 'warning');
}
if (payload.data && payload.data.media_cleanup && payload.data.media_cleanup.errors && payload.data.media_cleanup.errors.length) {
Adminx.Toast.show('Документ сохранён, но часть старых файлов не перемещена в корзину.', 'warning');
}
if (payload.data && payload.data.id) {
self.field('id').value = payload.data.id;
self.form.setAttribute('data-id', payload.data.id);
@@ -1392,6 +1403,7 @@
if (payload.data && payload.data.media_draft_token) {
self.refreshMediaDraft(payload.data.media_draft_token, payload.data.id || id);
}
self.resetMediaReplacements();
if (self.form.getAttribute('data-quick-edit') === '1') {
self.refreshQuickEditOpener(!stay);
return;
@@ -2480,10 +2492,20 @@
openMediaUpload: function (list) {
var input = list ? list.querySelector('[data-document-media-files]') : null;
if (input) { input.click(); }
if (input) {
input.removeAttribute('data-replace-upload');
input.click();
}
},
uploadMediaFiles: function (container, files) {
openMediaReplacement: function (container) {
var input = container ? container.querySelector('[data-document-media-files]') : null;
if (!input) { return; }
input.setAttribute('data-replace-upload', '1');
input.click();
},
uploadMediaFiles: function (container, files, replace) {
if (!container || !files || !files.length) { return; }
var data = new FormData();
data.append('_csrf', this.csrf());
@@ -2495,6 +2517,9 @@
var self = this;
this.ajax(container.getAttribute('data-upload-url') || (this.base() + '/media/upload'), data, function (payload) {
var uploaded = ((payload.data || {}).files) || [];
if (replace && uploaded.length) {
self.prepareMediaReplacement(container);
}
if (container.matches('[data-document-media-single]')) {
self.applyUploadedSingle(container, uploaded[0] || null);
} else {
@@ -2625,17 +2650,14 @@
if (!list) { return; }
var self = this;
var clear = function () {
var box = list.querySelector('[data-document-media-items]');
if (box) { box.innerHTML = ''; }
self.updateMediaListEmpty(list);
self.setDirty(true);
self.prepareMediaReplacement(list);
};
if (Adminx.Confirm) {
Adminx.Confirm.open({
kind: 'danger',
title: 'Очистить поле?',
message: 'Все элементы этого поля будут удалены из документа после сохранения.',
confirmLabel: 'Очистить',
title: 'Удалить все элементы?',
message: 'После сохранения элементы исчезнут из документа. Неиспользуемые файлы можно будет восстановить из корзины медиа.',
confirmLabel: 'Удалить все',
onConfirm: clear
});
return;
@@ -2643,6 +2665,68 @@
if (confirm('Очистить все элементы поля?')) { clear(); }
},
clearSingleMedia: function (container) {
if (!container) { return; }
var self = this;
var clear = function () { self.prepareMediaReplacement(container); };
if (Adminx.Confirm) {
Adminx.Confirm.open({
kind: 'danger',
title: 'Удалить изображение?',
message: 'После сохранения изображение исчезнет из документа. Неиспользуемый файл можно будет восстановить из корзины медиа.',
confirmLabel: 'Удалить',
onConfirm: clear
});
return;
}
if (confirm('Удалить изображение из документа?')) { clear(); }
},
prepareMediaReplacement: function (container) {
if (!container) { return; }
if (container.matches('[data-document-media-list]')) {
var box = container.querySelector('[data-document-media-items]');
if (box) { box.innerHTML = ''; }
this.updateMediaListEmpty(container);
} else {
var input = container.querySelector('[data-document-media-url]');
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}
this.markMediaReplacement(container);
this.setDirty(true);
},
markMediaReplacement: function (container) {
if (!this.form || !container) { return; }
var fieldId = parseInt(container.getAttribute('data-field-id'), 10) || 0;
if (!fieldId) { return; }
var existing = this.form.querySelector('input[name="media_replace_fields[]"][value="' + fieldId + '"]');
if (!existing) {
existing = document.createElement('input');
existing.type = 'hidden';
existing.name = 'media_replace_fields[]';
existing.value = String(fieldId);
this.form.appendChild(existing);
}
container.classList.add('is-replacing');
var note = container.querySelector('[data-document-media-replacement-note]');
if (note) { note.hidden = false; }
},
resetMediaReplacements: function () {
if (!this.form) { return; }
this.form.querySelectorAll('input[name="media_replace_fields[]"]').forEach(function (input) { input.remove(); });
this.form.querySelectorAll('.is-replacing').forEach(function (container) {
container.classList.remove('is-replacing');
var note = container.querySelector('[data-document-media-replacement-note]');
if (note) { note.hidden = true; }
});
},
updateMediaPreview: function (input) {
var key = input.getAttribute('data-media-key');
if (key && key !== 'url') { return; }
@@ -3326,16 +3410,20 @@
var remove = document.querySelector('[data-document-revision-delete]');
var publicPreview = document.querySelector('[data-document-revision-preview]');
if (title) { title.textContent = 'Ревизия #' + (item.id || ''); }
if (meta) { meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.size_label ? ' · ' + item.size_label : ''); }
if (meta) {
var changedCount = (item.document_preview || []).filter(function (field) { return !!field.changed; }).length
+ (item.preview || []).filter(function (field) { return !!field.changed; }).length;
meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.size_label ? ' · ' + item.size_label : '') + ' · изменений: ' + changedCount;
}
if (fields) {
var preview = item.preview || [];
var documentPreview = item.document_preview || [];
var systemHtml = documentPreview.length ? '<section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Основные настройки</b><span>' + self.esc(documentPreview.length) + '</span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>Все</span></label></div>' + documentPreview.map(function (field) {
return '<article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Восстановить ' + self.esc(field.title || field.key) + '"><input type="checkbox" value="' + self.esc(field.key || '') + '" data-document-revision-select="document" checked></label><span class="documents-revision-field-name"><b>' + self.esc(field.title || field.key) + '</b><small>' + self.esc(field.key || '') + '</small></span></div><pre>' + self.esc(field.value_preview || '') + '</pre></article>';
return '<article class="documents-revision-field documents-revision-system-field ' + (field.changed ? 'is-changed' : 'is-unchanged') + '"><div><label class="documents-revision-check" aria-label="Восстановить ' + self.esc(field.title || field.key) + '"><input type="checkbox" value="' + self.esc(field.key || '') + '" data-document-revision-select="document"' + (field.changed ? ' checked' : '') + '></label><span class="documents-revision-field-name"><b>' + self.esc(field.title || field.key) + '</b><small>' + self.esc(field.key || '') + '</small></span><span class="badge ' + (field.changed ? 'badge-amber' : 'badge-gray') + '">' + (field.changed ? 'изменится' : 'совпадает') + '</span></div><pre>' + self.esc(field.value_preview || '') + '</pre></article>';
}).join('') + '</section>' : '';
var fieldsHtml = preview.length ? '<section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Поля рубрики</b><span>' + self.esc(preview.length) + '</span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>Все</span></label></div>' + preview.map(function (field) {
return '<article class="documents-revision-field">'
+ '<div><label class="documents-revision-check" aria-label="Восстановить ' + self.esc(field.title || ('Поле #' + field.field_id)) + '"><input type="checkbox" value="' + self.esc(field.field_id) + '" data-document-revision-select="field" checked></label><span class="documents-revision-field-name"><b>' + self.esc(field.title || ('Поле #' + field.field_id)) + '</b><small>#' + self.esc(field.field_id) + (field.type ? ' · ' + self.esc(field.type) : '') + '</small></span><span>' + self.esc(field.size_label || '') + '</span></div>'
return '<article class="documents-revision-field ' + (field.changed ? 'is-changed' : 'is-unchanged') + '">'
+ '<div><label class="documents-revision-check" aria-label="Восстановить ' + self.esc(field.title || ('Поле #' + field.field_id)) + '"><input type="checkbox" value="' + self.esc(field.field_id) + '" data-document-revision-select="field"' + (field.changed ? ' checked' : '') + '></label><span class="documents-revision-field-name"><b>' + self.esc(field.title || ('Поле #' + field.field_id)) + '</b><small>#' + self.esc(field.field_id) + (field.type ? ' · ' + self.esc(field.type) : '') + '</small></span><span class="badge ' + (field.changed ? 'badge-amber' : 'badge-gray') + '">' + (field.changed ? 'изменится' : 'совпадает') + '</span><span>' + self.esc(field.size_label || '') + '</span></div>'
+ '<pre>' + self.esc(field.value_preview || '') + '</pre>'
+ '</article>';
}).join('') + '</section>' : '<div class="empty-state">В снимке нет значений полей.</div>';
@@ -3466,3 +3554,242 @@
document.addEventListener('DOMContentLoaded', function () { Adminx.Documents.init(); });
})(window, document);
/**
* Universal document batch editor. The server owns the frozen ID list; this
* client only renders the preview and advances the prepared plan by chunks.
*/
(function (window, document) {
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
var root = document.querySelector('[data-document-bulk-editor]');
if (!root) { return; }
var form = root.querySelector('[data-document-bulk-form]');
var operation = root.querySelector('[data-bulk-operation]');
var scope = root.querySelector('[name="scope"]');
var rubric = root.querySelector('[data-bulk-rubric]');
var target = root.querySelector('[data-bulk-target]');
var fieldGroup = root.querySelector('[data-bulk-field-options]');
var previewPanel = root.querySelector('[data-bulk-preview-panel]');
var progressPanel = root.querySelector('[data-bulk-progress-panel]');
var token = '';
var stopped = false;
function updateTargetHelp() {
var help = root.querySelector('[data-bulk-target-help]');
if (!help || !target) { return; }
var selected = target.options[target.selectedIndex];
var message = selected ? String(selected.getAttribute('data-help') || '') : '';
if (selected && selected.value === 'document_title' && scope && scope.value === 'products') {
message = 'Это заголовок документа в панели. Название товара на сайте обычно берётся из поля рубрики, помеченного «витрина: название товара на сайте».';
}
var text = help.querySelector('span');
if (text) { text.textContent = message; }
help.hidden = message === '';
}
function endpoint(path) {
return Adminx.base() + '/documents/bulk-editor' + path;
}
function body(payload) {
var response = payload && payload.data ? payload.data : {};
if (!payload || !payload.ok || response.success === false) {
throw new Error(response.message || 'Операция не выполнена');
}
return response.data || {};
}
function post(path, data) {
return Adminx.Ajax.post(endpoint(path), data).then(body);
}
function setVisibility(selector, visible) {
var node = root.querySelector(selector);
if (node) { node.hidden = !visible; }
}
function updateOperation() {
var value = operation ? operation.value : '';
var editsField = ['fill', 'set', 'clear', 'replace'].indexOf(value) !== -1;
setVisibility('[data-bulk-target-wrap]', editsField);
setVisibility('[data-bulk-search-wrap]', value === 'replace');
setVisibility('[data-bulk-value-wrap]', value === 'fill' || value === 'set' || value === 'replace');
setVisibility('[data-bulk-rubric-target-wrap]', value === 'move');
updateTargetHelp();
}
function loadFields() {
if (!fieldGroup) { return; }
fieldGroup.replaceChildren();
var id = rubric ? Number(rubric.value || 0) : 0;
if (!id) {
var empty = document.createElement('option');
empty.disabled = true;
empty.textContent = 'Сначала выберите рубрику';
fieldGroup.appendChild(empty);
return;
}
Adminx.Ajax.request(endpoint('/fields?rubric_id=' + encodeURIComponent(id))).then(function (payload) {
var data = body(payload);
(data.items || []).forEach(function (item) {
var option = document.createElement('option');
option.value = 'field:' + item.id;
option.textContent = item.title + (item.alias ? ' · ' + item.alias : '') + ' [' + item.type + ']'
+ (item.usage ? ' · витрина: ' + item.usage : '');
option.setAttribute('data-help', item.help || '');
fieldGroup.appendChild(option);
});
if (!fieldGroup.children.length) {
var empty = document.createElement('option');
empty.disabled = true;
empty.textContent = 'В рубрике нет полей';
fieldGroup.appendChild(empty);
}
updateTargetHelp();
}).catch(function (error) {
Adminx.Toast.show(error.message || 'Не удалось загрузить поля рубрики', 'error');
});
}
function cell(text, className) {
var td = document.createElement('td');
if (className) { td.className = className; }
td.textContent = text == null ? '' : String(text);
return td;
}
function renderPreview(plan) {
token = plan.token || '';
var rows = root.querySelector('[data-bulk-preview-rows]');
rows.replaceChildren();
(plan.sample || []).forEach(function (item) {
var tr = document.createElement('tr');
var identity = document.createElement('td');
var title = document.createElement('b');
var meta = document.createElement('small');
title.textContent = item.title;
meta.textContent = '#' + item.id + ' · ' + item.state;
identity.appendChild(title);
identity.appendChild(meta);
tr.appendChild(identity);
tr.appendChild(cell(item.rubric));
tr.appendChild(cell(item.before, 'documents-bulk-value-cell'));
tr.appendChild(cell(item.after, 'documents-bulk-value-cell'));
var result = cell(item.changed ? 'Изменится' : (item.note || 'Без изменений'));
result.className = item.changed ? 'documents-bulk-result is-changed' : 'documents-bulk-result is-skipped';
tr.appendChild(result);
rows.appendChild(tr);
});
root.querySelector('[data-bulk-preview-count]').textContent = plan.total || 0;
root.querySelector('[data-bulk-preview-summary]').textContent =
(plan.operation && plan.operation.label ? plan.operation.label : 'Действие') +
(plan.operation && plan.operation.target_label ? ' · поле: ' + plan.operation.target_label : '') +
' · найдено ' + (plan.matched_total || plan.total || 0) +
', изменится ' + (plan.total || 0) + '.';
previewPanel.hidden = false;
progressPanel.hidden = true;
previewPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function renderProgress(plan) {
var finished = plan.status === 'completed' || plan.status === 'cancelled';
var percent = Number(plan.progress || 0);
progressPanel.hidden = false;
root.querySelector('[data-bulk-progress-percent]').textContent = percent + '%';
root.querySelector('[data-bulk-progress-bar]').style.width = percent + '%';
root.querySelector('[data-bulk-progress-processed]').textContent = plan.processed || 0;
root.querySelector('[data-bulk-progress-done]').textContent = plan.done || 0;
root.querySelector('[data-bulk-progress-skipped]').textContent = plan.skipped || 0;
root.querySelector('[data-bulk-progress-errors]').textContent = (plan.errors || []).length;
root.querySelector('[data-bulk-progress-title]').textContent =
plan.status === 'completed' ? 'Массовое изменение завершено' :
(plan.status === 'cancelled' ? 'Выполнение остановлено' : 'Обрабатываем документы');
root.querySelector('[data-bulk-progress-message]').textContent =
'Обработано ' + (plan.processed || 0) + ' из ' + (plan.total || 0);
root.querySelector('[data-bulk-cancel]').hidden = finished;
root.querySelector('[data-bulk-finish]').hidden = !finished;
var errors = root.querySelector('[data-bulk-errors]');
errors.hidden = !(plan.errors || []).length;
errors.replaceChildren();
(plan.errors || []).forEach(function (message) {
var line = document.createElement('div');
line.textContent = message;
errors.appendChild(line);
});
}
function runNext() {
if (stopped || !token) { return; }
var data = new FormData();
data.append('token', token);
post('/run', data).then(function (result) {
var plan = result.plan || {};
renderProgress(plan);
if (plan.status === 'running') {
window.setTimeout(runNext, 120);
} else {
Adminx.Toast.show(plan.errors && plan.errors.length ? 'Готово с ошибками' : 'Массовое изменение завершено', plan.errors && plan.errors.length ? 'warning' : 'success');
}
}).catch(function (error) {
renderProgress({ status: 'cancelled', total: 0, processed: 0, errors: [error.message] });
Adminx.Toast.show(error.message || 'Выполнение остановлено из-за ошибки', 'error');
});
}
if (operation) { operation.addEventListener('change', updateOperation); }
if (rubric) { rubric.addEventListener('change', loadFields); }
if (scope) { scope.addEventListener('change', updateTargetHelp); }
if (target) { target.addEventListener('change', updateTargetHelp); }
updateOperation();
loadFields();
form.addEventListener('submit', function (event) {
event.preventDefault();
var button = form.querySelector('[data-bulk-preview]');
button.disabled = true;
Adminx.Loader.show();
post('/preview', new FormData(form)).then(function (result) {
renderPreview(result.plan || {});
Adminx.Toast.show('Предпросмотр подготовлен', 'success');
}).catch(function (error) {
Adminx.Toast.show(error.message || 'Не удалось подготовить предпросмотр', 'error');
}).then(function () {
button.disabled = false;
Adminx.Loader.hide();
});
});
root.addEventListener('click', function (event) {
var run = event.target.closest('[data-bulk-run]');
if (run) {
Adminx.Confirm.open({
kind: 'warning',
title: 'Применить изменения ко всему набору?',
message: 'Будут обработаны все документы из зафиксированного предпросмотра. Для изменённых записей сохранятся ревизии.',
confirmLabel: 'Запустить',
onConfirm: function () {
stopped = false;
previewPanel.hidden = true;
renderProgress({ status: 'running', total: Number(root.querySelector('[data-bulk-preview-count]').textContent || 0), processed: 0, done: 0, skipped: 0, errors: [], progress: 0 });
progressPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
runNext();
}
});
return;
}
if (event.target.closest('[data-bulk-cancel]')) {
stopped = true;
var data = new FormData();
data.append('token', token);
post('/cancel', data).then(function (result) {
renderProgress(result.plan || {});
Adminx.Toast.show('Выполнение остановлено', 'warning');
}).catch(function (error) {
Adminx.Toast.show(error.message || 'Не удалось остановить выполнение', 'error');
});
}
});
})(window, document);
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_bulk_title">Bulk editor</phrase>
<phrase data="documents_bulk_intro">Checks changes before starting and processes documents in small batches.</phrase>
<phrase data="documents_bulk_notice_title">Review first, then apply</phrase>
<phrase data="documents_bulk_notice_text">The preview freezes the exact document list. Changed documents get normal revisions, hooks run, and indexes are refreshed.</phrase>
<phrase data="documents_bulk_filter_title">1. Select documents</phrase>
<phrase data="documents_bulk_filter_help">Narrow the set first. Filters do not change data.</phrase>
<phrase data="documents_bulk_scope">Scope</phrase>
<phrase data="documents_bulk_scope_all">Documents and products</phrase>
<phrase data="documents_bulk_scope_documents">Documents only</phrase>
<phrase data="documents_bulk_scope_products">Products only</phrase>
<phrase data="documents_bulk_all_rubrics">All rubrics</phrase>
<phrase data="documents_bulk_state_all">Published and drafts</phrase>
<phrase data="documents_bulk_state_active">Published only</phrase>
<phrase data="documents_bulk_state_draft">Drafts only</phrase>
<phrase data="documents_bulk_change_title">2. Select changes</phrase>
<phrase data="documents_bulk_change_help">Select a single rubric above before changing rubric fields.</phrase>
<phrase data="documents_bulk_action">Action</phrase>
<phrase data="documents_bulk_choose_action">Select an action</phrase>
<phrase data="documents_bulk_fill">Fill empty values only</phrase>
<phrase data="documents_bulk_set">Set value</phrase>
<phrase data="documents_bulk_clear">Clear value</phrase>
<phrase data="documents_bulk_replace">Find and replace</phrase>
<phrase data="documents_bulk_move">Move to another rubric</phrase>
<phrase data="documents_bulk_publish">Publish</phrase>
<phrase data="documents_bulk_unpublish">Unpublish</phrase>
<phrase data="documents_bulk_recalculate">Recalculate fields and indexes</phrase>
<phrase data="documents_bulk_core_fields">Document data</phrase>
<phrase data="documents_bulk_rubric_fields">Selected rubric fields</phrase>
<phrase data="documents_bulk_find">Find</phrase>
<phrase data="documents_bulk_exact">Exact substring</phrase>
<phrase data="documents_bulk_new_value">New value</phrase>
<phrase data="documents_bulk_enter_value">Enter a value</phrase>
<phrase data="documents_bulk_new_rubric">New rubric</phrase>
<phrase data="documents_bulk_choose_rubric">Select a rubric</phrase>
<phrase data="documents_bulk_no_save">Nothing is saved until the preview is confirmed.</phrase>
<phrase data="documents_bulk_preview_button">Preview changes</phrase>
<phrase data="documents_bulk_preview_title">3. Preview</phrase>
<phrase data="documents_bulk_document_column">ID and document</phrase>
<phrase data="documents_bulk_before">Before</phrase>
<phrase data="documents_bulk_after">After</phrase>
<phrase data="documents_bulk_result">Result</phrase>
<phrase data="documents_bulk_review_title">Review examples before starting</phrase>
<phrase data="documents_bulk_review_help">The table shows the first 20 documents. The operation applies to the entire frozen set.</phrase>
<phrase data="documents_bulk_apply">Apply to the entire set</phrase>
<phrase data="documents_bulk_processing">Processing documents</phrase>
<phrase data="documents_bulk_preparing">Preparing...</phrase>
<phrase data="documents_bulk_processed">processed</phrase>
<phrase data="documents_bulk_changed">changed</phrase>
<phrase data="documents_bulk_unchanged">unchanged</phrase>
<phrase data="documents_bulk_errors">errors</phrase>
<phrase data="documents_bulk_stop">Stop</phrase>
<phrase data="documents_bulk_back">Back to documents</phrase>
<phrase data="documents_bulk_choose_rubric_first">Select a rubric first</phrase>
<phrase data="documents_bulk_no_fields">The rubric has no fields</phrase>
<phrase data="documents_bulk_fields_error">Could not load rubric fields</phrase>
<phrase data="documents_bulk_will_change">Will change</phrase>
<phrase data="documents_bulk_action_fallback">Action</phrase>
<phrase data="documents_bulk_found_prefix"> · found </phrase>
<phrase data="documents_bulk_documents_suffix"> documents.</phrase>
<phrase data="documents_bulk_processed_prefix">Processed </phrase>
<phrase data="documents_bulk_done_errors">Completed with errors</phrase>
<phrase data="documents_bulk_stopped_error">Execution stopped because of an error</phrase>
<phrase data="documents_bulk_preview_ready">Preview prepared</phrase>
<phrase data="documents_bulk_preview_error">Could not prepare preview</phrase>
<phrase data="documents_bulk_confirm_title">Apply changes to the entire set?</phrase>
<phrase data="documents_bulk_confirm_message">All documents in the frozen preview will be processed. Revisions are saved for changed records.</phrase>
<phrase data="documents_bulk_start">Start</phrase>
<phrase data="documents_bulk_stop_error">Could not stop execution</phrase>
</language>
@@ -228,6 +228,12 @@
<phrase data="auto_fc8b3939cc9dc467">The path will be written in the document field.</phrase>
<phrase data="auto_fda74d41e3311f87">A newer version is already saved on the server. Refresh the page, check the changes, and save the document again.</phrase>
<phrase data="auto_ffec67789fb1fcdc">sch</phrase>
<phrase data="documents_bulk_action">Action</phrase>
<phrase data="documents_bulk_action_fallback">Action</phrase>
<phrase data="documents_bulk_documents_suffix"> documents.</phrase>
<phrase data="documents_bulk_errors">errors</phrase>
<phrase data="documents_bulk_processed">processed</phrase>
<phrase data="documents_bulk_unchanged">unchanged</phrase>
<phrase data="runtime_05d4ae78b6f1bfee">n</phrase>
<phrase data="runtime_0918b4ba92684eaa">Title</phrase>
<phrase data="runtime_0940401102b8a3fe">t</phrase>
@@ -287,4 +293,10 @@
<phrase data="runtime_f4019994d4e75a2a">after saving</phrase>
<phrase data="runtime_f9a506077fa0d961">New</phrase>
<phrase data="runtime_fff8b34d92dab340">Template</phrase>
<phrase data="auto_f8af664b7ab2ef75">Remove all items?</phrase>
<phrase data="auto_10403ca9b11a0b49">After saving, the items will be removed from the document. Unused files can be restored from the media trash.</phrase>
<phrase data="auto_5ede041b60096d58">Remove the image?</phrase>
<phrase data="auto_c5b3cd090f16f479">After saving, the image will be removed from the document. The unused file can be restored from the media trash.</phrase>
<phrase data="auto_e26c8664b60bfa9a">Remove the image from the document?</phrase>
<phrase data="auto_02e536f7b2d5cec8">The document was saved, but some old files were not moved to the trash.</phrase>
</language>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_bulk_title">Массовый редактор</phrase>
<phrase data="documents_bulk_intro">Проверяет изменения до запуска и обрабатывает документы небольшими пакетами.</phrase>
<phrase data="documents_bulk_notice_title">Сначала проверка, затем применение</phrase>
<phrase data="documents_bulk_notice_text">Предпросмотр фиксирует точный список документов. Для изменённых документов создаются обычные ревизии, выполняются хуки и обновляются индексы.</phrase>
<phrase data="documents_bulk_filter_title">1. Какие документы</phrase>
<phrase data="documents_bulk_filter_help">Сначала сузьте набор. Фильтр не меняет данные.</phrase>
<phrase data="documents_bulk_scope">Контур</phrase>
<phrase data="documents_bulk_scope_all">Документы и товары</phrase>
<phrase data="documents_bulk_scope_documents">Только обычные документы</phrase>
<phrase data="documents_bulk_scope_products">Только товары</phrase>
<phrase data="documents_bulk_all_rubrics">Все рубрики</phrase>
<phrase data="documents_bulk_state_all">Опубликованные и черновики</phrase>
<phrase data="documents_bulk_state_active">Только опубликованные</phrase>
<phrase data="documents_bulk_state_draft">Только черновики</phrase>
<phrase data="documents_bulk_change_title">2. Что изменить</phrase>
<phrase data="documents_bulk_change_help">Для полей рубрики сначала выберите одну рубрику выше.</phrase>
<phrase data="documents_bulk_action">Действие</phrase>
<phrase data="documents_bulk_choose_action">Выберите действие</phrase>
<phrase data="documents_bulk_fill">Заполнить только пустые</phrase>
<phrase data="documents_bulk_set">Установить значение</phrase>
<phrase data="documents_bulk_clear">Очистить значение</phrase>
<phrase data="documents_bulk_replace">Найти и заменить</phrase>
<phrase data="documents_bulk_move">Перенести в другую рубрику</phrase>
<phrase data="documents_bulk_publish">Опубликовать</phrase>
<phrase data="documents_bulk_unpublish">Снять с публикации</phrase>
<phrase data="documents_bulk_recalculate">Пересчитать поля и индексы</phrase>
<phrase data="documents_bulk_core_fields">Основные данные документа</phrase>
<phrase data="documents_bulk_rubric_fields">Поля выбранной рубрики</phrase>
<phrase data="documents_bulk_find">Что найти</phrase>
<phrase data="documents_bulk_exact">Точное вхождение</phrase>
<phrase data="documents_bulk_new_value">Новое значение</phrase>
<phrase data="documents_bulk_enter_value">Введите значение</phrase>
<phrase data="documents_bulk_new_rubric">Новая рубрика</phrase>
<phrase data="documents_bulk_choose_rubric">Выберите рубрику</phrase>
<phrase data="documents_bulk_no_save">Ничего не сохранится до подтверждения результата проверки.</phrase>
<phrase data="documents_bulk_preview_button">Проверить изменения</phrase>
<phrase data="documents_bulk_preview_title">3. Предпросмотр</phrase>
<phrase data="documents_bulk_document_column">ID и документ</phrase>
<phrase data="documents_bulk_before">Было</phrase>
<phrase data="documents_bulk_after">Станет</phrase>
<phrase data="documents_bulk_result">Результат</phrase>
<phrase data="documents_bulk_review_title">Проверьте примеры перед запуском</phrase>
<phrase data="documents_bulk_review_help">В таблице показаны первые 20 документов. Выполнение затронет весь зафиксированный набор.</phrase>
<phrase data="documents_bulk_apply">Применить ко всему набору</phrase>
<phrase data="documents_bulk_processing">Обрабатываем документы</phrase>
<phrase data="documents_bulk_preparing">Подготовка...</phrase>
<phrase data="documents_bulk_processed">обработано</phrase>
<phrase data="documents_bulk_changed">изменено</phrase>
<phrase data="documents_bulk_unchanged">без изменений</phrase>
<phrase data="documents_bulk_errors">ошибок</phrase>
<phrase data="documents_bulk_stop">Остановить</phrase>
<phrase data="documents_bulk_back">Вернуться к документам</phrase>
<phrase data="documents_bulk_choose_rubric_first">Сначала выберите рубрику</phrase>
<phrase data="documents_bulk_no_fields">В рубрике нет полей</phrase>
<phrase data="documents_bulk_fields_error">Не удалось загрузить поля рубрики</phrase>
<phrase data="documents_bulk_will_change">Изменится</phrase>
<phrase data="documents_bulk_action_fallback">Действие</phrase>
<phrase data="documents_bulk_found_prefix"> · найдено </phrase>
<phrase data="documents_bulk_documents_suffix"> документов.</phrase>
<phrase data="documents_bulk_processed_prefix">Обработано </phrase>
<phrase data="documents_bulk_done_errors">Готово с ошибками</phrase>
<phrase data="documents_bulk_stopped_error">Выполнение остановлено из-за ошибки</phrase>
<phrase data="documents_bulk_preview_ready">Предпросмотр подготовлен</phrase>
<phrase data="documents_bulk_preview_error">Не удалось подготовить предпросмотр</phrase>
<phrase data="documents_bulk_confirm_title">Применить изменения ко всему набору?</phrase>
<phrase data="documents_bulk_confirm_message">Будут обработаны все документы из зафиксированного предпросмотра. Для изменённых записей сохранятся ревизии.</phrase>
<phrase data="documents_bulk_start">Запустить</phrase>
<phrase data="documents_bulk_stop_error">Не удалось остановить выполнение</phrase>
</language>
@@ -228,6 +228,12 @@
<phrase data="auto_fc8b3939cc9dc467">Путь будет записан в поле документа.</phrase>
<phrase data="auto_fda74d41e3311f87">На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно.</phrase>
<phrase data="auto_ffec67789fb1fcdc">щ</phrase>
<phrase data="documents_bulk_action">Действие</phrase>
<phrase data="documents_bulk_action_fallback">Действие</phrase>
<phrase data="documents_bulk_documents_suffix"> документов.</phrase>
<phrase data="documents_bulk_errors">ошибок</phrase>
<phrase data="documents_bulk_processed">обработано</phrase>
<phrase data="documents_bulk_unchanged">без изменений</phrase>
<phrase data="runtime_05d4ae78b6f1bfee">н</phrase>
<phrase data="runtime_0918b4ba92684eaa">Название</phrase>
<phrase data="runtime_0940401102b8a3fe">т</phrase>
@@ -287,4 +293,10 @@
<phrase data="runtime_f4019994d4e75a2a">после сохранения</phrase>
<phrase data="runtime_f9a506077fa0d961">Новый</phrase>
<phrase data="runtime_fff8b34d92dab340">Шаблон</phrase>
<phrase data="auto_f8af664b7ab2ef75">Удалить все элементы?</phrase>
<phrase data="auto_10403ca9b11a0b49">После сохранения элементы исчезнут из документа. Неиспользуемые файлы можно будет восстановить из корзины медиа.</phrase>
<phrase data="auto_5ede041b60096d58">Удалить изображение?</phrase>
<phrase data="auto_c5b3cd090f16f479">После сохранения изображение исчезнет из документа. Неиспользуемый файл можно будет восстановить из корзины медиа.</phrase>
<phrase data="auto_e26c8664b60bfa9a">Удалить изображение из документа?</phrase>
<phrase data="auto_02e536f7b2d5cec8">Документ сохранён, но часть старых файлов не перемещена в корзину.</phrase>
</language>
+8 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'documents',
'name' => 'Документы',
'version' => '0.1.7',
'version' => '0.3.3',
'permissions' => array(
'key' => 'documents',
@@ -65,12 +65,19 @@
'routes' => array(
array('GET', '/documents', array(\App\Adminx\Documents\Controller::class, 'index')),
array('POST', '/documents/saved-views', array(\App\Adminx\Documents\Controller::class, 'saveSavedView')),
array('POST', '/documents/saved-views/{id}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteSavedView')),
array('GET', '/documents/create', array(\App\Adminx\Documents\Controller::class, 'create')),
array('GET', '/documents/alias-check', array(\App\Adminx\Documents\Controller::class, 'aliasCheck')),
array('POST', '/documents/slug', array(\App\Adminx\Documents\Controller::class, 'slug')),
array('POST', '/documents/short-alias', array(\App\Adminx\Documents\Controller::class, 'shortAlias')),
array('POST', '/documents/preview', array(\App\Adminx\Documents\Controller::class, 'previewPayload')),
array('POST', '/documents/bulk', array(\App\Adminx\Documents\Controller::class, 'bulk')),
array('GET', '/documents/bulk-editor', array(\App\Adminx\Documents\Controller::class, 'bulkEditor')),
array('GET', '/documents/bulk-editor/fields', array(\App\Adminx\Documents\Controller::class, 'bulkEditorFields')),
array('POST', '/documents/bulk-editor/preview', array(\App\Adminx\Documents\Controller::class, 'bulkEditorPreview')),
array('POST', '/documents/bulk-editor/run', array(\App\Adminx\Documents\Controller::class, 'bulkEditorRun')),
array('POST', '/documents/bulk-editor/cancel', array(\App\Adminx\Documents\Controller::class, 'bulkEditorCancel')),
array('POST', '/documents/snapshots/rebuild', array(\App\Adminx\Documents\Controller::class, 'rebuildSnapshots')),
array('GET', '/documents/picker', array(\App\Adminx\Documents\Controller::class, 'documentPicker')),
array('GET', '/documents/terms', array(\App\Adminx\Documents\Controller::class, 'termSuggestions')),
+1
View File
@@ -23,6 +23,7 @@
<div class="tabs documents-page-tabs" role="tablist" aria-label="Разделы документов">
<a class="tab" href="{{ ADMINX_BASE }}/documents"><i class="ti ti-files"></i>Документы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/bulk-editor"><i class="ti ti-edit-circle"></i>Массовый редактор</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/views"><i class="ti ti-chart-line"></i>Просмотры</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/redirects"><i class="ti ti-route"></i>Редиректы</a>
<a class="tab is-active" href="{{ ADMINX_BASE }}/documents/api"><i class="ti ti-api"></i>JSON API</a>
@@ -0,0 +1,159 @@
{% extends '@adminx/main.twig' %}
{% block title %}Массовый редактор{% endblock %}
{% block content %}
<nav class="breadcrumbs" aria-label="Хлебные крошки">
<a href="{{ ADMINX_BASE }}/">Главная</a><i class="ti ti-chevron-right"></i>
<a href="{{ ADMINX_BASE }}/documents">Документы</a><i class="ti ti-chevron-right"></i>
<span>Массовый редактор</span>
</nav>
<div class="page-header">
<div>
<h1>Массовый редактор</h1>
<p class="text-secondary" style="margin-top:5px">Проверяет изменения до запуска и обрабатывает документы небольшими пакетами.</p>
</div>
</div>
<div class="tabs documents-page-tabs" role="tablist" aria-label="Разделы документов">
<a class="tab" href="{{ ADMINX_BASE }}/documents"><i class="ti ti-files"></i>Документы</a>
<a class="tab is-active" href="{{ ADMINX_BASE }}/documents/bulk-editor"><i class="ti ti-edit-circle"></i>Массовый редактор</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/views"><i class="ti ti-chart-line"></i>Просмотры</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/redirects"><i class="ti ti-route"></i>Редиректы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/api"><i class="ti ti-api"></i>JSON API</a>
</div>
<div class="documents-bulk-editor" data-document-bulk-editor>
<div class="alert alert-info documents-bulk-notice">
<i class="ti ti-shield-check"></i>
<div><b>Сначала проверка, затем применение</b><span>Предпросмотр фиксирует точный список документов. Для изменённых документов создаются обычные ревизии, выполняются хуки и обновляются индексы.</span></div>
</div>
<form class="documents-bulk-form" data-document-bulk-form>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<section class="card documents-bulk-step">
<div class="documents-section-head">
<div class="documents-section-title">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-filter"></i></span>
<div><h2>1. Какие документы</h2><p class="text-secondary">Сначала сузьте набор. Фильтр не меняет данные.</p></div>
</div>
</div>
<div class="documents-bulk-controls">
{% if bulk_options.products_available %}
<label class="field"><span>Контур</span><select class="select" name="scope">
<option value="all">Документы и товары</option>
<option value="documents">Только обычные документы</option>
<option value="products">Только товары</option>
</select></label>
{% else %}
<input type="hidden" name="scope" value="all">
{% endif %}
<label class="field"><span>Рубрика</span><select class="select" name="rubric_id" data-bulk-rubric>
<option value="0">Все рубрики</option>
{% for rubric in bulk_options.rubrics %}
<option value="{{ rubric.Id }}">{{ rubric.rubric_title }}</option>
{% endfor %}
</select></label>
<label class="field"><span>Состояние</span><select class="select" name="state">
<option value="">Опубликованные и черновики</option>
<option value="active">Только опубликованные</option>
<option value="draft">Только черновики</option>
</select></label>
<label class="field documents-bulk-search"><span>Поиск</span><span class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" placeholder="ID, название или alias"></span></label>
</div>
</section>
<section class="card documents-bulk-step">
<div class="documents-section-head">
<div class="documents-section-title">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-adjustments-horizontal"></i></span>
<div><h2>2. Что изменить</h2><p class="text-secondary">Для полей рубрики сначала выберите одну рубрику выше.</p></div>
</div>
</div>
<div class="documents-bulk-controls">
<label class="field"><span>Действие</span><select class="select" name="operation" data-bulk-operation>
<option value="">Выберите действие</option>
<option value="fill">Заполнить только пустые</option>
<option value="set">Установить значение</option>
<option value="clear">Очистить значение</option>
<option value="replace">Найти и заменить</option>
<option value="move">Перенести в другую рубрику</option>
<option value="publish">Опубликовать</option>
<option value="unpublish">Снять с публикации</option>
<option value="recalculate">Пересчитать поля и индексы</option>
</select></label>
<label class="field" data-bulk-target-wrap hidden><span>Поле</span><select class="select" name="target" data-bulk-target>
<optgroup label="Основные данные документа">
{% for key, item in bulk_options.document_fields %}
<option value="{{ key }}" data-help="{{ item.help|default('') }}">{{ item.label }}</option>
{% endfor %}
</optgroup>
<optgroup label="Поля выбранной рубрики" data-bulk-field-options></optgroup>
</select></label>
<label class="field" data-bulk-search-wrap hidden><span>Что найти</span><input class="input" type="text" name="search" placeholder="Точное вхождение"></label>
<label class="field documents-bulk-value" data-bulk-value-wrap hidden><span>Новое значение</span><textarea class="textarea" name="value" rows="3" placeholder="Введите значение"></textarea></label>
<label class="field" data-bulk-rubric-target-wrap hidden><span>Новая рубрика</span><select class="select" name="target_rubric_id">
<option value="0">Выберите рубрику</option>
{% for rubric in bulk_options.rubrics %}
<option value="{{ rubric.Id }}">{{ rubric.rubric_title }}</option>
{% endfor %}
</select></label>
</div>
<div class="documents-bulk-target-help" data-bulk-target-help hidden>
<i class="ti ti-info-circle"></i><span></span>
</div>
<div class="documents-bulk-step-footer">
<span class="text-secondary"><i class="ti ti-info-circle"></i>Ничего не сохранится до подтверждения результата проверки.</span>
<button class="btn btn-primary" type="submit" data-bulk-preview><i class="ti ti-eye"></i>Проверить изменения</button>
</div>
</section>
</form>
<section class="card documents-bulk-preview" data-bulk-preview-panel hidden>
<div class="documents-section-head">
<div class="documents-section-title">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-list-check"></i></span>
<div><h2>3. Предпросмотр</h2><p class="text-secondary" data-bulk-preview-summary></p></div>
</div>
<span class="badge badge-blue" data-bulk-preview-count>0</span>
</div>
<div class="table-responsive">
<table class="table documents-bulk-table">
<thead><tr><th>ID и документ</th><th>Рубрика</th><th>Было</th><th>Станет</th><th>Результат</th></tr></thead>
<tbody data-bulk-preview-rows></tbody>
</table>
</div>
<div class="documents-bulk-run">
<div>
<b>Проверьте примеры перед запуском</b>
<span>В таблице показаны первые 20 изменений. В очередь попадут только документы, где значение действительно изменится.</span>
</div>
<button class="btn btn-primary" type="button" data-bulk-run><i class="ti ti-player-play"></i>Применить ко всему набору</button>
</div>
</section>
<section class="card documents-bulk-progress" data-bulk-progress-panel hidden>
<div class="documents-bulk-progress-head">
<div><b data-bulk-progress-title>Обрабатываем документы</b><span data-bulk-progress-message>Подготовка...</span></div>
<strong data-bulk-progress-percent>0%</strong>
</div>
<div class="progress"><span data-bulk-progress-bar style="width:0%"></span></div>
<div class="documents-bulk-progress-stats">
<span><b data-bulk-progress-processed>0</b> обработано</span>
<span><b data-bulk-progress-done>0</b> изменено</span>
<span><b data-bulk-progress-skipped>0</b> без изменений</span>
<span><b data-bulk-progress-errors>0</b> ошибок</span>
</div>
<div class="documents-bulk-progress-actions">
<button class="btn btn-secondary" type="button" data-bulk-cancel><i class="ti ti-player-stop"></i>Остановить</button>
<a class="btn btn-primary" href="{{ ADMINX_BASE }}/documents" data-bulk-finish hidden><i class="ti ti-check"></i>Вернуться к документам</a>
</div>
<div class="documents-bulk-errors" data-bulk-errors hidden></div>
</section>
</div>
{% endblock %}
+2 -1
View File
@@ -15,6 +15,7 @@
<p class="text-secondary" style="margin-top:5px" data-document-edit-meta>{{ is_new ? (catalog_mode ? 'Заполните основные данные. Характеристики, варианты и доставка откроются после первого сохранения.' : 'Создание базовой карточки документа.') : 'ID ' ~ document.Id ~ ', ' ~ (document.state_label|admin_trans) ~ ', изменён: ' ~ document.changed_label }}</p>
</div>
<div class="cluster">
{% if catalog_mode and not is_new and can_manage and product_copy_url %}<button class="btn btn-secondary" type="button" data-catalog-product-copy data-url="{{ product_copy_url }}" data-csrf="{{ csrf_token }}"><i class="ti ti-copy"></i>Создать копию</button>{% endif %}
<a class="btn btn-ghost" href="{{ return_url|default(ADMINX_BASE ~ '/documents') }}"><i class="ti ti-arrow-left"></i>К списку</a>
</div>
</div>
@@ -363,7 +364,7 @@
{% else %}
<div class="ax-document-field-body documents-field-control">{{ field.html|raw }}</div>
{% endif %}
{% if field.rubric_field_description %}<p class="documents-field-description">{{ field.rubric_field_description }}</p>{% endif %}
{% if field.rubric_field_description_html %}<div class="documents-field-description">{{ field.rubric_field_description_html|raw }}</div>{% endif %}
</div>
{% endfor %}
</div>
+2 -1
View File
@@ -42,6 +42,7 @@
<div class="tabs documents-page-tabs" role="tablist" aria-label="Разделы документов">
<a class="tab is-active" href="{{ ADMINX_BASE }}/documents"><i class="ti ti-files"></i>Документы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/bulk-editor"><i class="ti ti-edit-circle"></i>Массовый редактор</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/views"><i class="ti ti-chart-line"></i>Просмотры</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/redirects"><i class="ti ti-route"></i>Редиректы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/api"><i class="ti ti-api"></i>JSON API</a>
@@ -78,7 +79,7 @@
<p class="text-secondary">{{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }} в текущем наборе.</p>
</div>
</div>
<span class="badge badge-blue">{{ filter_state_label }}</span>
<div class="cluster">{% set saved_views_form = '.documents-filter' %}{% set saved_views_save_url = ADMINX_BASE ~ '/documents/saved-views' %}{% set saved_views_delete_url = ADMINX_BASE ~ '/documents/saved-views/__ID__/delete' %}{% set saved_views_fields = ['q','field','rubric_id','state','per_page'] %}{% include '@adminx/_saved_views.twig' %}<span class="badge badge-blue">{{ filter_state_label }}</span></div>
</div>
<div class="table-toolbar documents-toolbar">
<form class="documents-filter" method="get" action="{{ ADMINX_BASE }}/documents">
@@ -7,6 +7,7 @@
<div class="tabs documents-page-tabs" role="tablist" aria-label="Разделы документов">
<a class="tab" href="{{ ADMINX_BASE }}/documents"><i class="ti ti-files"></i>Документы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/bulk-editor"><i class="ti ti-edit-circle"></i>Массовый редактор</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/views"><i class="ti ti-chart-line"></i>Просмотры</a>
<a class="tab is-active" href="{{ ADMINX_BASE }}/documents/redirects"><i class="ti ti-route"></i>Редиректы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/api"><i class="ti ti-api"></i>JSON API</a>
+1
View File
@@ -7,6 +7,7 @@
<div class="tabs documents-page-tabs" role="tablist" aria-label="Разделы документов">
<a class="tab" href="{{ ADMINX_BASE }}/documents"><i class="ti ti-files"></i>Документы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/bulk-editor"><i class="ti ti-edit-circle"></i>Массовый редактор</a>
<a class="tab is-active" href="{{ ADMINX_BASE }}/documents/views"><i class="ti ti-chart-line"></i>Просмотры</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/redirects"><i class="ti ti-route"></i>Редиректы</a>
<a class="tab" href="{{ ADMINX_BASE }}/documents/api"><i class="ti ti-api"></i>JSON API</a>
+50 -5
View File
@@ -17,6 +17,7 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Common\SystemTables;
use App\Adminx\Support\GlobalSearchRegistry;
use App\Adminx\Support\ModuleExtensions;
use App\Content\ContentTables;
@@ -57,7 +58,7 @@
self::decode($row['document_title']),
'#' . (int) $row['Id'] . ' · /' . (string) $row['document_alias'],
'/documents/' . (int) $row['Id'] . '/edit',
'ti-file-text'
'ti ti-file-text'
);
$item['score'] = (float) $row['search_relevance'];
$out[] = $item;
@@ -79,7 +80,7 @@
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('rubric', 'Рубрики', self::decode($row['rubric_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['rubric_alias'], '/rubrics?edit=' . (int) $row['Id'], 'ti-forms');
$out[] = self::item('rubric', 'Рубрики', self::decode($row['rubric_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['rubric_alias'], '/rubrics?edit=' . (int) $row['Id'], 'ti ti-forms');
}
return $out;
@@ -98,7 +99,7 @@
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('block', 'Блоки', self::decode($row['sysblock_name']), '#' . (int) $row['id'] . ' · ' . (string) $row['sysblock_alias'], '/blocks?edit=' . (int) $row['id'], 'ti-blockquote');
$out[] = self::item('block', 'Блоки', self::decode($row['sysblock_name']), '#' . (int) $row['id'] . ' · ' . (string) $row['sysblock_alias'], '/blocks?edit=' . (int) $row['id'], 'ti ti-blockquote');
}
return $out;
@@ -117,7 +118,7 @@
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('request', 'Запросы', self::decode($row['request_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['request_alias'], '/requests/' . (int) $row['Id'], 'ti-list-search');
$out[] = self::item('request', 'Запросы', self::decode($row['request_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['request_alias'], '/requests/' . (int) $row['Id'], 'ti ti-list-search');
}
return $out;
@@ -142,7 +143,50 @@
self::decode($row['title']),
'#' . (int) $row['navigation_item_id'] . ' · ' . (string) $row['alias'],
'/navigation?navigation=' . (int) $row['navigation_id'] . '&item=' . (int) $row['navigation_item_id'],
'ti-menu-2'
'ti ti-menu-2'
);
}
return $out;
}
public static function users($query, $limit = 6)
{
$table = SystemTables::table('users');
if (!self::available($table)) { return array(); }
// У таблицы пользователей смешанные коллации (email обычно ascii,
// name/login — utf8mb4), поэтому приводим сравниваемые колонки к
// charset соединения (utf8 = utf8mb3, см. dbchar): иначе
// кириллический запрос роняет провайдер на «illegal mix of
// collations». Ведущий подстановочный знак и так исключает
// использование индекса, так что CONVERT ничего не стоит.
$rows = DB::query(
'SELECT id,name,login,email,is_active FROM ' . $table
. ' WHERE CONVERT(name USING utf8) LIKE %ss'
. ' OR CONVERT(login USING utf8) LIKE %ss'
. ' OR CONVERT(email USING utf8) LIKE %ss OR id=%i'
. ' ORDER BY is_active DESC,name LIMIT ' . max(1, min(12, (int) $limit)),
$query,
$query,
$query,
(int) $query
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$name = trim((string) $row['name']);
$login = trim((string) $row['login']);
$email = trim((string) $row['email']);
$title = $name !== '' ? $name : ($login !== '' ? '@' . $login : $email);
$parts = array('#' . (int) $row['id']);
if ($email !== '') { $parts[] = $email; }
if (empty($row['is_active'])) { $parts[] = 'выключен'; }
$out[] = self::item(
'user',
'Пользователи',
self::decode($title !== '' ? $title : ('#' . (int) $row['id'])),
implode(' · ', $parts),
'/users/' . (int) $row['id'],
'ti ti-user'
);
}
@@ -168,6 +212,7 @@
'blocks' => array('provider' => array(self::class, 'blocks'), 'permission' => 'view_blocks', 'priority' => 30, 'limit' => 6),
'requests' => array('provider' => array(self::class, 'requests'), 'permission' => 'view_requests', 'priority' => 40, 'limit' => 6),
'navigation' => array('provider' => array(self::class, 'navigation'), 'permission' => 'view_navigation', 'priority' => 50, 'limit' => 5),
'users' => array('provider' => array(self::class, 'users'), 'permission' => 'view_users', 'priority' => 60, 'limit' => 6),
) as $code => $definition) {
GlobalSearchRegistry::register('core.' . $code, $definition);
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="global_search_name">Global search</phrase>
<phrase data="global_search_results">Results</phrase>
<phrase data="global_search_documents">Documents</phrase>
<phrase data="global_search_rubrics">Rubrics</phrase>
<phrase data="global_search_blocks">Blocks</phrase>
<phrase data="global_search_requests">Requests</phrase>
<phrase data="global_search_navigation">Navigation</phrase>
<phrase data="global_search_users">Users</phrase>
<phrase data="global_search_customers">Site users</phrase>
<phrase data="global_search_media">Media</phrase>
</language>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="global_search_name">Глобальный поиск</phrase>
<phrase data="global_search_results">Результаты</phrase>
<phrase data="global_search_documents">Документы</phrase>
<phrase data="global_search_rubrics">Рубрики</phrase>
<phrase data="global_search_blocks">Блоки</phrase>
<phrase data="global_search_requests">Запросы</phrase>
<phrase data="global_search_navigation">Навигация</phrase>
<phrase data="global_search_users">Пользователи</phrase>
<phrase data="global_search_customers">Пользователи сайта</phrase>
<phrase data="global_search_media">Медиа</phrase>
</language>
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'global_search',
'name' => 'Глобальный поиск',
'version' => '0.2.0',
'version' => '0.2.1',
'routes' => array(
array('GET', '/search', array(\App\Adminx\GlobalSearch\Controller::class, 'index')),
),
+73
View File
@@ -17,6 +17,8 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\AdminAssets;
use App\Common\AuditLog;
use App\Common\Auth;
use App\Common\Controller as BaseController;
use App\Common\Permission;
use App\Helpers\Request;
@@ -43,6 +45,28 @@
]);
}
/** GET /roles/simulator — read-only explanation of effective access. */
public function simulator(array $params = [])
{
AdminAssets::addStyle($this->base() . '/modules/Groups/assets/groups.css', 50);
AdminAssets::addScript($this->base() . '/modules/Groups/assets/groups.js', 50);
$options = PermissionSimulator::options();
$role = Request::getStr('role', '');
$user = Request::getInt('user', 0);
try {
$result = PermissionSimulator::simulate($role, $user);
} catch (\Throwable $e) {
$result = PermissionSimulator::simulate('admin', 0);
}
return $this->render('@groups/simulator.twig', [
'title' => 'Симулятор прав',
'options' => $options,
'result' => $result,
]);
}
/** GET /roles/{id} — роль + её права (JSON, для формы). */
public function show(array $params = [])
{
@@ -91,6 +115,55 @@
return $this->success('Роль создана', ['redirect' => $this->base() . '/roles']);
}
/** POST /roles/{id}/copy — новая роль с тем же набором прав. */
public function copy(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$role = Model::find($params['id'] ?? 0);
if (!$role) {
return $this->error('Роль не найдена', [], 404);
}
//-- Полный доступ admin держится фолбэком ядра, а не строками прав:
//-- копия получилась бы пустой, поэтому честно отказываем.
if ((string) $role->code === 'admin') {
return $this->error('Роль «admin» копировать нельзя: её полный доступ не хранится списком прав', [], 422);
}
$code = strtolower(trim(Request::postStr('code')));
if ($code === '') { $code = Model::freeCode((string) $role->code); }
$name = trim(Request::postStr('name'));
if ($name === '') { $name = mb_substr((string) $role->name . ' — копия', 0, 190, 'UTF-8'); }
$errors = [];
if (!preg_match('/^[a-z][a-z0-9_]{1,49}$/', $code)) {
$errors['code'] = 'Код: латиница/цифры/подчёркивание, с буквы.';
} elseif (Model::codeExists($code)) {
$errors['code'] = 'Роль с таким кодом уже есть.';
}
if ($errors) {
return $this->error('Проверьте поля формы', $errors);
}
try {
$newId = Model::copy((int) $role->id, $code, $name);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), [], 422);
}
AuditLog::record('role.copied', [
'actor_id' => Auth::id(),
'target_type' => 'role',
'target_id' => (int) $newId,
'meta' => ['source_id' => (int) $role->id, 'source_code' => (string) $role->code, 'code' => $code],
]);
return $this->success('Роль скопирована', ['redirect' => $this->base() . '/roles']);
}
/** POST /roles/{id} — обновить название и права. */
public function update(array $params = [])
{
+49
View File
@@ -221,6 +221,55 @@
}
}
/**
* Копия роли вместе с набором прав.
*
* Новая роль всегда обычная: системный признак не наследуется, иначе
* копию нельзя было бы ни переименовать, ни удалить. Пользователи в
* копию не переносятся их назначают отдельно.
*/
public static function copy($id, $code, $name)
{
$source = self::find($id);
if (!$source) { throw new \RuntimeException('Роль не найдена'); }
//-- Без явного режима исключений ошибка уникальности кода оборвала бы
//-- выполнение мимо rollback и оставила роль без прав.
$previousMode = DB::$throw_exception_on_error;
DB::$throw_exception_on_error = true;
$newId = 0;
DB::startTransaction();
try {
$newId = (int) self::create($code, $name);
if ($newId < 1) { throw new \RuntimeException('Не удалось создать копию роли'); }
self::setPermissions($newId, self::permissionCodes((int) $source->id));
DB::commit();
} catch (\Throwable $e) {
if (DB::$transaction_in_progress) { DB::rollback(); }
throw $e;
} finally {
DB::$throw_exception_on_error = $previousMode;
}
return $newId;
}
/** Свободный код вида role_copy, role_copy_2 — на случай повторных копий. */
public static function freeCode($base)
{
$base = trim(preg_replace('/[^a-z0-9_]+/', '_', strtolower((string) $base)), '_');
if ($base === '') { $base = 'role'; }
$base = substr($base, 0, 40) . '_copy';
$candidate = $base;
$suffix = 2;
while (self::codeExists($candidate) && $suffix < 100) {
$candidate = $base . '_' . $suffix;
$suffix++;
}
return $candidate;
}
public static function delete($id)
{
$id = (int) $id;
@@ -0,0 +1,310 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Groups/PermissionSimulator.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Groups;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Language;
use App\Common\Navigation;
use App\Common\Permission;
use App\Common\PublicAuthSettings;
use App\Common\SystemTables;
use App\Content\ContentTables;
use App\Content\PublicUserTables;
use App\Frontend\RubricPermissionResolver;
use DB;
/** Read-only explanation of effective panel and public-content permissions. */
class PermissionSimulator
{
public static function options()
{
return array(
'roles' => DB::query(
'SELECT id, code, name, is_system FROM ' . SystemTables::table('roles')
. ' ORDER BY is_system DESC, name ASC'
)->getAll() ?: array(),
'users' => DB::query(
'SELECT id, name, email, role, is_active FROM ' . SystemTables::table('users')
. ' ORDER BY name ASC, id ASC'
)->getAll() ?: array(),
);
}
public static function simulate($roleCode, $userId)
{
$subject = self::subject($roleCode, $userId);
$permissionCodes = Permission::forRole($subject['role_code']);
$all = $subject['role_code'] === 'admin';
$permissionMap = array_fill_keys($permissionCodes, true);
if ($all) {
$permissionMap['all_permissions'] = true;
}
$can = function ($code) use ($all, $permissionMap) {
return $all || isset($permissionMap[(string) $code]);
};
$sections = array();
foreach (Navigation::all() as $item) {
$visible = isset($item['visible']) ? $item['visible'] : true;
$stateVisible = is_callable($visible) ? (bool) call_user_func($visible) : (bool) $visible;
$permission = isset($item['permission']) ? (string) $item['permission'] : '';
$allowed = $stateVisible && ($permission === '' || $can($permission));
$sections[] = array(
'code' => (string) $item['code'],
'label' => Language::translateSource((string) $item['label']),
'group' => Language::translateSource((string) $item['group']),
'url' => (string) $item['url'],
'icon' => (string) $item['icon'],
'permission' => $permission,
'allowed' => $allowed,
'reason' => !$stateVisible
? 'Раздел скрыт текущим состоянием системы'
: self::permissionReason($permission, $allowed, $all),
);
}
$actions = array();
$allowedActions = 0;
$moduleLabels = Model::permissionModuleLabels();
foreach (Model::allPermissionsGrouped() as $module => $permissions) {
$rows = array();
$allowedInModule = 0;
foreach ($permissions as $permission) {
$allowed = $can($permission['code']);
if ($allowed) {
$allowedActions++;
$allowedInModule++;
}
$permission['allowed'] = $allowed;
$permission['reason'] = self::permissionReason($permission['code'], $allowed, $all);
$rows[] = $permission;
}
$actions[] = array(
'module' => $module,
'label' => isset($moduleLabels[$module])
? $moduleLabels[$module]
: $module,
'allowed_count' => $allowedInModule,
'items' => $rows,
);
}
$rubrics = self::rubrics($subject['public_group_id'], $can);
$documents = self::documents($rubrics, $can);
$allowedSections = count(array_filter($sections, function ($item) {
return !empty($item['allowed']);
}));
$readableRubrics = count(array_filter($rubrics, function ($item) {
return !empty($item['public_read']);
}));
return array(
'subject' => $subject,
'permissions' => array_values($permissionCodes),
'sections' => $sections,
'actions' => $actions,
'rubrics' => $rubrics,
'documents' => $documents,
'summary' => array(
'sections_allowed' => $allowedSections,
'sections_total' => count($sections),
'actions_allowed' => $allowedActions,
'actions_total' => array_sum(array_map(function ($group) {
return count($group['items']);
}, $actions)),
'rubrics_readable' => $readableRubrics,
'rubrics_total' => count($rubrics),
'documents_visible' => count(array_filter($documents, function ($item) {
return !empty($item['public_read']);
})),
),
);
}
protected static function subject($roleCode, $userId)
{
$userId = (int) $userId;
$user = null;
if ($userId > 0) {
$user = DB::query(
'SELECT u.id, u.name, u.email, u.role, u.is_active, u.legacy_id,'
. ' pu.user_group, pug.user_group_name'
. ' FROM ' . SystemTables::table('users') . ' u'
. ' LEFT JOIN ' . PublicUserTables::table('users') . ' pu ON pu.Id = u.legacy_id'
. ' LEFT JOIN ' . PublicUserTables::table('user_groups') . ' pug ON pug.user_group = pu.user_group'
. ' WHERE u.id = %i LIMIT 1',
$userId
)->getAssoc();
}
if ($user) {
$roleCode = (string) $user['role'];
} else {
$roleCode = trim((string) $roleCode);
if ($roleCode === '') {
$roleCode = 'admin';
}
}
$role = DB::query(
'SELECT id, code, name, is_system FROM ' . SystemTables::table('roles')
. ' WHERE code = %s LIMIT 1',
$roleCode
)->getAssoc();
if (!$role) {
throw new \InvalidArgumentException('Выбранная роль не найдена');
}
$publicGroupId = $user && !empty($user['user_group'])
? (int) $user['user_group']
: self::defaultPublicGroup($roleCode);
$publicGroupName = $user && !empty($user['user_group_name'])
? (string) $user['user_group_name']
: (string) DB::query(
'SELECT user_group_name FROM ' . PublicUserTables::table('user_groups')
. ' WHERE user_group = %i LIMIT 1',
$publicGroupId
)->getValue();
return array(
'type' => $user ? 'user' : 'role',
'user_id' => $user ? (int) $user['id'] : 0,
'user_name' => $user ? (string) $user['name'] : '',
'user_email' => $user ? (string) $user['email'] : '',
'user_active' => $user ? (bool) $user['is_active'] : true,
'role_id' => (int) $role['id'],
'role_code' => (string) $role['code'],
'role_name' => (string) $role['name'],
'public_group_id' => $publicGroupId,
'public_group_name' => $publicGroupName !== '' ? $publicGroupName : 'Группа #' . $publicGroupId,
);
}
protected static function rubrics($publicGroupId, callable $can)
{
$rows = DB::query(
'SELECT r.Id, r.rubric_title, r.rubric_alias, r.rubric_purpose,'
. ' rp.rubric_permission, COUNT(d.Id) AS document_count'
. ' FROM ' . ContentTables::table('rubrics') . ' r'
. ' LEFT JOIN ' . ContentTables::table('rubric_permissions') . ' rp'
. ' ON rp.rubric_id = r.Id AND rp.user_group_id = %i'
. ' LEFT JOIN ' . ContentTables::table('documents') . ' d'
. ' ON d.rubric_id = r.Id AND d.document_deleted = %s'
. ' GROUP BY r.Id, r.rubric_title, r.rubric_alias, r.rubric_purpose, rp.rubric_permission'
. ' ORDER BY r.rubric_position ASC, r.rubric_title ASC',
(int) $publicGroupId,
'0'
)->getAll() ?: array();
$resolver = new RubricPermissionResolver();
$result = array();
foreach ($rows as $row) {
$row = (array) $row;
$permissions = self::parseRubricPermissions($row['rubric_permission']);
$row['permissions'] = $permissions;
$row['panel_view'] = $can('view_rubrics');
$row['panel_manage'] = $can('manage_rubrics');
$row['documents_panel_view'] = $can('view_documents');
$row['documents_panel_manage'] = $can('manage_documents');
$row['public_read'] = $resolver->allows($permissions, 'docread');
$row['public_create'] = $resolver->allows($permissions, 'new')
|| $resolver->allows($permissions, 'newnow');
$row['public_edit'] = $resolver->allows($permissions, 'editall')
|| $resolver->allows($permissions, 'editown');
$row['reason'] = $row['public_read']
? 'Группа имеет право чтения или полный доступ'
: 'Для публичной группы не назначено docread';
$result[] = $row;
}
return $result;
}
protected static function documents(array $rubrics, callable $can)
{
$rubricMap = array();
foreach ($rubrics as $rubric) {
$rubricMap[(int) $rubric['Id']] = $rubric;
}
$rows = DB::query(
'SELECT d.Id, d.document_title, d.document_alias, d.document_status,'
. ' d.document_deleted, d.rubric_id, r.rubric_title'
. ' FROM ' . ContentTables::table('documents') . ' d'
. ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id = d.rubric_id'
. ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT 100'
)->getAll() ?: array();
$result = array();
foreach ($rows as $row) {
$row = (array) $row;
$rubric = isset($rubricMap[(int) $row['rubric_id']]) ? $rubricMap[(int) $row['rubric_id']] : array();
$row['panel_view'] = $can('view_documents');
$row['panel_edit'] = $can('manage_documents');
$row['public_read'] = !empty($rubric['public_read']) && (string) $row['document_deleted'] !== '1';
if ((string) $row['document_deleted'] === '1') {
$row['reason'] = 'Документ удалён и публично отдаёт 404';
} elseif (!empty($rubric['public_read'])) {
$row['reason'] = 'Разрешён docread для рубрики';
} else {
$row['reason'] = 'Нет docread для рубрики';
}
$result[] = $row;
}
return $result;
}
protected static function permissionReason($code, $allowed, $all)
{
if ($code === '') {
return 'Отдельное право не требуется';
}
if ($all) {
return 'Роль администратора имеет полный доступ';
}
return $allowed
? 'Право ' . $code . ' назначено роли'
: 'Право ' . $code . ' не назначено роли';
}
protected static function defaultPublicGroup($role)
{
$settings = PublicAuthSettings::all();
$registered = isset($settings['default_group']) ? max(1, (int) $settings['default_group']) : 4;
$map = array('admin' => 1, 'guest' => 2, 'moderator' => 3, 'user' => $registered);
return isset($map[$role]) ? $map[$role] : $registered;
}
protected static function parseRubricPermissions($serialized)
{
$result = array();
foreach (explode('|', (string) $serialized) as $permission) {
$permission = trim($permission);
if ($permission !== '' && preg_match('/^[a-z0-9_-]+$/i', $permission)) {
$result[$permission] = $permission;
}
}
return array_values($result);
}
}
+280
View File
@@ -4,3 +4,283 @@
--section-accent: var(--violet-500);
--section-accent-soft: var(--violet-100);
}
.groups-tabs {
margin-bottom: 18px;
}
.groups-tabs .tab {
text-decoration: none;
}
.permission-simulator-picker {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) minmax(190px, 0.75fr) minmax(240px, 1fr) auto;
align-items: end;
gap: 14px;
margin-bottom: 14px;
padding: 16px;
}
.permission-simulator-picker-copy {
display: flex;
align-items: flex-start;
gap: 12px;
}
.permission-simulator-picker-copy h2,
.permission-simulator-picker-copy p {
margin: 0;
}
.permission-simulator-picker-copy h2 {
margin-bottom: 3px;
font-size: 15px;
}
.permission-simulator-picker-copy p {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.permission-simulator-warning {
margin-bottom: 14px;
}
.permission-simulator-subject {
display: grid;
grid-template-columns: minmax(240px, 0.8fr) minmax(420px, 1.2fr);
align-items: center;
gap: 20px;
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--background-surface);
}
.permission-simulator-subject > div {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.permission-simulator-subject > div > div {
min-width: 0;
}
.permission-simulator-subject b,
.permission-simulator-subject small {
display: block;
}
.permission-simulator-subject small {
overflow: hidden;
color: var(--text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-simulator-subject dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.permission-simulator-subject dl div {
min-width: 0;
}
.permission-simulator-subject dt {
margin-bottom: 2px;
color: var(--text-muted);
font-size: 11px;
}
.permission-simulator-subject dd {
margin: 0;
font-size: 13px;
}
.permission-simulator-subject dd .mono {
margin-left: 5px;
color: var(--text-muted);
font-size: 11px;
}
.permission-simulator-summary,
.permission-simulator-header {
margin-bottom: 18px;
}
.permission-sections-table th:nth-child(1) {
width: 28%;
}
.permission-sections-table th:nth-child(2) {
width: 150px;
}
.permission-sections-table th:nth-child(3) {
width: 220px;
}
.permission-sections-table td:last-child,
.permission-rubrics-table td:last-child {
min-width: 260px;
}
.permission-sections-table td:last-child small,
.permission-rubrics-table td small,
.permission-documents-sample td small {
display: block;
margin-top: 3px;
color: var(--text-muted);
font-size: 11px;
}
.permission-section-name {
display: inline-flex;
align-items: center;
gap: 8px;
}
.permission-section-name i {
width: 26px;
height: 26px;
display: inline-grid;
place-items: center;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
color: var(--color-primary);
}
.permission-result {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.permission-result.is-allowed {
color: var(--color-success);
}
.permission-result.is-denied {
color: var(--color-danger);
}
.permission-sections-table tr.is-denied,
.permission-rubrics-table tr.is-denied,
.permission-documents-sample tr.is-denied {
opacity: 0.68;
}
.permission-action-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 22px;
}
.permission-action-group {
padding: 0;
overflow: hidden;
}
.permission-action-group summary,
.permission-documents-sample summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 20px;
align-items: center;
gap: 12px;
min-height: 54px;
padding: 10px 14px;
cursor: pointer;
list-style: none;
}
.permission-action-group summary::-webkit-details-marker,
.permission-documents-sample summary::-webkit-details-marker {
display: none;
}
.permission-action-group summary > span:first-child,
.permission-documents-sample summary > span:first-child {
display: flex;
align-items: center;
gap: 8px;
}
.permission-action-group summary > span:nth-child(2),
.permission-documents-sample summary > span:nth-child(2) {
color: var(--text-muted);
font-size: 12px;
}
.permission-action-group[open] summary,
.permission-documents-sample[open] summary {
border-bottom: 1px solid var(--border-default);
}
.permission-action-group[open] summary > i,
.permission-documents-sample[open] summary > i {
transform: rotate(180deg);
}
.permission-action-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid var(--border-default);
}
.permission-action-row:last-child {
border-bottom: 0;
}
.permission-action-row.is-denied {
opacity: 0.62;
}
.permission-action-row b,
.permission-action-row small {
display: block;
}
.permission-action-row b {
font-size: 12px;
}
.permission-action-row small {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
}
.permission-action-row > .mono {
color: var(--text-muted);
font-size: 10px;
}
.permission-rubrics-table th:nth-child(2),
.permission-rubrics-table th:nth-child(3),
.permission-rubrics-table th:nth-child(4),
.permission-rubrics-table th:nth-child(5) {
width: 105px;
text-align: center;
}
.permission-rubrics-table td:nth-child(2),
.permission-rubrics-table td:nth-child(3),
.permission-rubrics-table td:nth-child(4),
.permission-rubrics-table td:nth-child(5) {
text-align: center;
}
.permission-rubrics-table td:first-child a,
.permission-documents-sample td:first-child a {
color: var(--text-primary);
text-decoration: none;
}
.permission-documents-sample {
margin-top: 14px;
padding: 0;
overflow: hidden;
}
@media (max-width: 1040px) {
.permission-simulator-picker {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-simulator-picker-copy {
grid-column: 1 / -1;
}
.permission-action-groups {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.permission-simulator-picker,
.permission-simulator-subject,
.permission-simulator-subject dl {
grid-template-columns: 1fr;
}
.permission-simulator-picker .btn {
width: 100%;
}
.permission-action-group summary,
.permission-documents-sample summary {
grid-template-columns: minmax(0, 1fr) 20px;
}
.permission-action-group summary > span:nth-child(2),
.permission-documents-sample summary > span:nth-child(2) {
display: none;
}
.permission-action-row {
grid-template-columns: 24px minmax(0, 1fr);
}
.permission-action-row > .mono {
grid-column: 2;
}
}
+26
View File
@@ -22,6 +22,8 @@
if (edit) { self.fillEdit(edit.closest('tr')); }
var del = e.target.closest('[data-role-delete]');
if (del) { self.remove(del.closest('tr')); }
var copy = e.target.closest('[data-role-copy]');
if (copy) { self.copy(copy.closest('tr')); }
if (e.target.closest('[data-perms-all]')) { self.setAll(true); }
if (e.target.closest('[data-perms-none]')) { self.setAll(false); }
});
@@ -122,6 +124,30 @@
});
},
copy: function (row) {
var id = row.dataset.id;
var base = this.base();
Adminx.Confirm.open({
kind: 'info',
title: 'Создать копию роли?',
message: 'Новая роль получит те же права, что и «' + row.dataset.name + '». Пользователи не переносятся, набор прав можно поправить сразу после создания.',
confirmLabel: 'Создать копию',
onConfirm: function () {
Adminx.Loader.show();
Adminx.Ajax.post(base + '/roles/' + id + '/copy').then(function (payload) {
Adminx.Loader.hide();
var d = payload.data || {};
if (d.success === false) { Adminx.Toast.show(d.message || 'Не удалось скопировать', 'error'); return; }
Adminx.Toast.show(d.message || 'Роль скопирована', 'success');
window.location.reload();
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show('Ошибка сети', 'error');
});
}
});
},
remove: function (row) {
var id = row.dataset.id;
var base = this.base();
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="groups_simulator_title">Permission simulator</phrase>
<phrase data="groups_simulator_short">Simulator</phrase>
<phrase data="groups_simulator_intro">Shows effective access without signing in as the selected user or changing their session.</phrase>
<phrase data="groups_simulator_sections_aria">Roles and permissions sections</phrase>
<phrase data="groups_simulator_subject">Subject</phrase>
<phrase data="groups_simulator_subject_help">A selected user takes precedence over the role. A role uses its corresponding public group.</phrase>
<phrase data="groups_simulator_or_user">Or user</phrase>
<phrase data="groups_simulator_none">Not selected</phrase>
<phrase data="groups_simulator_disabled">disabled</phrase>
<phrase data="groups_simulator_show">Show access</phrase>
<phrase data="groups_simulator_account_disabled">Account disabled</phrase>
<phrase data="groups_simulator_account_disabled_help">The role permissions are shown below, but the user cannot sign in until the account is enabled.</phrase>
<phrase data="groups_simulator_role_only">Role check without a specific user</phrase>
<phrase data="groups_simulator_panel_role">Panel role</phrase>
<phrase data="groups_simulator_public_group">Public group</phrase>
<phrase data="groups_simulator_sections_count">Panel sections</phrase>
<phrase data="groups_simulator_actions_count">Actions</phrase>
<phrase data="groups_simulator_rubrics_count">Site rubrics</phrase>
<phrase data="groups_simulator_documents_count">Of the latest 100 documents</phrase>
<phrase data="groups_simulator_panel">Panel</phrase>
<phrase data="groups_simulator_sections">Sections</phrase>
<phrase data="groups_simulator_sections_help">What appears in the left menu and why each item is available or hidden.</phrase>
<phrase data="groups_simulator_requirement">Requirement</phrase>
<phrase data="groups_simulator_no_permission">no separate permission</phrase>
<phrase data="groups_simulator_allowed">Allowed</phrase>
<phrase data="groups_simulator_denied">Denied</phrase>
<phrase data="groups_simulator_actions">Actions</phrase>
<phrase data="groups_simulator_actions_help">All registered permissions grouped by section.</phrase>
<phrase data="groups_simulator_of">of</phrase>
<phrase data="groups_simulator_public">Public site</phrase>
<phrase data="groups_simulator_content">Rubrics and documents</phrase>
<phrase data="groups_simulator_content_help">Public group permissions are checked separately from panel permissions.</phrase>
<phrase data="groups_simulator_documents">Documents</phrase>
<phrase data="groups_simulator_read">Read</phrase>
<phrase data="groups_simulator_create">Create</phrase>
<phrase data="groups_simulator_edit">Edit</phrase>
<phrase data="groups_simulator_reason">Reason</phrase>
<phrase data="groups_simulator_not_assigned">permissions not assigned</phrase>
<phrase data="groups_simulator_no_rubrics">No rubrics found</phrase>
<phrase data="groups_simulator_recent">Latest 100 documents</phrase>
<phrase data="groups_simulator_show_direct">Show direct access check</phrase>
<phrase data="groups_simulator_view_edit">View and edit</phrase>
<phrase data="groups_simulator_view_only">View only</phrase>
<phrase data="groups_simulator_no_access">No access</phrase>
<phrase data="groups_simulator_unavailable">Unavailable</phrase>
</language>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="groups_simulator_title">Симулятор прав</phrase>
<phrase data="groups_simulator_short">Симулятор</phrase>
<phrase data="groups_simulator_intro">Показывает эффективный доступ без входа под выбранным пользователем и без изменения его сессии.</phrase>
<phrase data="groups_simulator_sections_aria">Разделы ролей и прав</phrase>
<phrase data="groups_simulator_subject">Кого проверяем</phrase>
<phrase data="groups_simulator_subject_help">Пользователь имеет приоритет над выбранной ролью. Для роли применяется соответствующая публичная группа.</phrase>
<phrase data="groups_simulator_or_user">Или пользователь</phrase>
<phrase data="groups_simulator_none">Не выбран</phrase>
<phrase data="groups_simulator_disabled">отключён</phrase>
<phrase data="groups_simulator_show">Показать доступ</phrase>
<phrase data="groups_simulator_account_disabled">Учётная запись отключена</phrase>
<phrase data="groups_simulator_account_disabled_help">Набор прав роли показан ниже, но пользователь не сможет войти в панель до включения учётной записи.</phrase>
<phrase data="groups_simulator_role_only">Проверка роли без конкретного пользователя</phrase>
<phrase data="groups_simulator_panel_role">Роль панели</phrase>
<phrase data="groups_simulator_public_group">Публичная группа</phrase>
<phrase data="groups_simulator_sections_count">Разделов панели</phrase>
<phrase data="groups_simulator_actions_count">Действий</phrase>
<phrase data="groups_simulator_rubrics_count">Рубрик на сайте</phrase>
<phrase data="groups_simulator_documents_count">Из последних 100 документов</phrase>
<phrase data="groups_simulator_panel">Панель</phrase>
<phrase data="groups_simulator_sections">Разделы</phrase>
<phrase data="groups_simulator_sections_help">Что появится в левом меню и почему пункт доступен или скрыт.</phrase>
<phrase data="groups_simulator_requirement">Требование</phrase>
<phrase data="groups_simulator_no_permission">без отдельного права</phrase>
<phrase data="groups_simulator_allowed">Доступен</phrase>
<phrase data="groups_simulator_denied">Запрещён</phrase>
<phrase data="groups_simulator_actions">Действия</phrase>
<phrase data="groups_simulator_actions_help">Все зарегистрированные права сгруппированы по разделам.</phrase>
<phrase data="groups_simulator_of">из</phrase>
<phrase data="groups_simulator_public">Публичная часть</phrase>
<phrase data="groups_simulator_content">Рубрики и документы</phrase>
<phrase data="groups_simulator_content_help">Права публичной группы проверяются отдельно от прав панели.</phrase>
<phrase data="groups_simulator_documents">Документов</phrase>
<phrase data="groups_simulator_read">Чтение</phrase>
<phrase data="groups_simulator_create">Создание</phrase>
<phrase data="groups_simulator_edit">Редактирование</phrase>
<phrase data="groups_simulator_reason">Причина</phrase>
<phrase data="groups_simulator_not_assigned">права не назначены</phrase>
<phrase data="groups_simulator_no_rubrics">Рубрики не найдены</phrase>
<phrase data="groups_simulator_recent">Последние 100 документов</phrase>
<phrase data="groups_simulator_show_direct">Показать проверку прямого доступа</phrase>
<phrase data="groups_simulator_view_edit">Просмотр и изменение</phrase>
<phrase data="groups_simulator_view_only">Только просмотр</phrase>
<phrase data="groups_simulator_no_access">Нет доступа</phrase>
<phrase data="groups_simulator_unavailable">Недоступен</phrase>
</language>
+3 -1
View File
@@ -21,7 +21,7 @@
return [
'code' => 'groups',
'name' => 'Роли и права',
'version' => '0.1.1',
'version' => '0.3.0',
'permissions' => [
'key' => 'groups',
@@ -62,9 +62,11 @@
'routes' => array(
array('GET', '/roles', array(\App\Adminx\Groups\Controller::class, 'index')),
array('GET', '/roles/simulator', array(\App\Adminx\Groups\Controller::class, 'simulator'), array('permission' => 'view_roles')),
array('GET', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'show')),
array('POST', '/roles', array(\App\Adminx\Groups\Controller::class, 'store')),
array('POST', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'update')),
array('POST', '/roles/{id}/copy', array(\App\Adminx\Groups\Controller::class, 'copy'), array('permission' => 'manage_roles')),
array('POST', '/roles/{id}/delete', array(\App\Adminx\Groups\Controller::class, 'destroy')),
),
+6
View File
@@ -19,6 +19,11 @@
</div>
</div>
<div class="tabs groups-tabs" role="navigation" aria-label="Разделы ролей и прав">
<a class="tab active" href="{{ ADMINX_BASE }}/roles" aria-current="page"><i class="ti ti-shield-lock"></i>Роли</a>
<a class="tab" href="{{ ADMINX_BASE }}/roles/simulator"><i class="ti ti-test-pipe"></i>Симулятор прав</a>
</div>
<div class="ax-summary">
<div class="card stat-tile"><div class="stat-ico" style="background:var(--color-violet-soft);color:var(--color-violet)"><i class="ti ti-shield-lock"></i></div><div><div class="stat-val">{{ stats.roles }}</div><div class="stat-label">Ролей</div></div></div>
<div class="card stat-tile"><div class="stat-ico" style="background:var(--background-inset);color:var(--text-muted)"><i class="ti ti-lock"></i></div><div><div class="stat-val">{{ stats.system }}</div><div class="stat-label">Системных</div></div></div>
@@ -72,6 +77,7 @@
<td>
<div class="cluster" style="gap:4px">
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-edit" data-role-edit data-open-drawer="roleDrawer" data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>
<button class="btn btn-ghost btn-icon btn-sm ax-act" data-role-copy data-tooltip="Создать копию с теми же правами" aria-label="Создать копию"><i class="ti ti-copy"></i></button>
{% if not r.is_system %}
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" data-role-delete data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
{% endif %}
+153
View File
@@ -0,0 +1,153 @@
{% extends '@adminx/main.twig' %}
{% block title %}Симулятор прав{% endblock %}
{% block content %}
<nav class="breadcrumbs" aria-label="Хлебные крошки">
<a href="{{ ADMINX_BASE }}/">Главная</a><i class="ti ti-chevron-right"></i>
<a href="{{ ADMINX_BASE }}/roles">Роли и права</a><i class="ti ti-chevron-right"></i>
<span>Симулятор</span>
</nav>
<div class="page-header">
<div>
<h1>Симулятор прав</h1>
<p class="text-secondary">Показывает эффективный доступ без входа под выбранным пользователем и без изменения его сессии.</p>
</div>
</div>
<div class="tabs groups-tabs" role="navigation" aria-label="Разделы ролей и прав">
<a class="tab" href="{{ ADMINX_BASE }}/roles"><i class="ti ti-shield-lock"></i>Роли</a>
<a class="tab active" href="{{ ADMINX_BASE }}/roles/simulator" aria-current="page"><i class="ti ti-test-pipe"></i>Симулятор прав</a>
</div>
<form class="card permission-simulator-picker" method="get" action="{{ ADMINX_BASE }}/roles/simulator">
<div class="permission-simulator-picker-copy">
<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-user-search"></i></span>
<div><h2>Кого проверяем</h2><p>Пользователь имеет приоритет над выбранной ролью. Для роли применяется соответствующая публичная группа.</p></div>
</div>
<label class="field">
<span class="field-label">Роль</span>
<select class="select" name="role">
{% for role in options.roles %}<option value="{{ role.code }}"{{ result.subject.role_code == role.code and result.subject.type == 'role' ? ' selected' : '' }}>{{ role.name }} · {{ role.code }}</option>{% endfor %}
</select>
</label>
<label class="field">
<span class="field-label">Или пользователь</span>
<select class="select" name="user">
<option value="0">Не выбран</option>
{% for user in options.users %}<option value="{{ user.id }}"{{ result.subject.user_id == user.id ? ' selected' : '' }}>#{{ user.id }} · {{ user.name }} · {{ user.role }}{% if not user.is_active %} · отключён{% endif %}</option>{% endfor %}
</select>
</label>
<button class="btn btn-primary" type="submit"><i class="ti ti-player-play"></i>Показать доступ</button>
</form>
{% if result.subject.type == 'user' and not result.subject.user_active %}
<div class="alert alert-warning permission-simulator-warning"><i class="ti ti-user-off alert-ic"></i><div><b>Учётная запись отключена</b><p>Набор прав роли показан ниже, но пользователь не сможет войти в панель до включения учётной записи.</p></div></div>
{% endif %}
<div class="permission-simulator-subject">
<div>
<span class="icon-tile" style="--tile-bg:var(--color-primary-soft);--tile-fg:var(--color-primary)"><i class="ti ti-user-shield"></i></span>
<div><b>{{ result.subject.type == 'user' ? result.subject.user_name : result.subject.role_name }}</b><small>{{ result.subject.type == 'user' ? result.subject.user_email : 'Проверка роли без конкретного пользователя' }}</small></div>
</div>
<dl>
<div><dt>Роль панели</dt><dd>{{ result.subject.role_name }} <span class="mono">{{ result.subject.role_code }}</span></dd></div>
<div><dt>Публичная группа</dt><dd>#{{ result.subject.public_group_id }} · {{ result.subject.public_group_name }}</dd></div>
</dl>
</div>
<div class="ax-summary permission-simulator-summary">
<div class="card stat-tile"><div class="stat-ico"><i class="ti ti-layout-sidebar"></i></div><div><div class="stat-val">{{ result.summary.sections_allowed }} / {{ result.summary.sections_total }}</div><div class="stat-label">Разделов панели</div></div></div>
<div class="card stat-tile"><div class="stat-ico" style="background:var(--color-violet-soft);color:var(--color-violet)"><i class="ti ti-key"></i></div><div><div class="stat-val">{{ result.summary.actions_allowed }} / {{ result.summary.actions_total }}</div><div class="stat-label">Действий</div></div></div>
<div class="card stat-tile"><div class="stat-ico" style="background:var(--color-success-soft);color:var(--color-success)"><i class="ti ti-forms"></i></div><div><div class="stat-val">{{ result.summary.rubrics_readable }} / {{ result.summary.rubrics_total }}</div><div class="stat-label">Рубрик на сайте</div></div></div>
<div class="card stat-tile"><div class="stat-ico" style="background:var(--color-warning-soft);color:var(--color-warning)"><i class="ti ti-files"></i></div><div><div class="stat-val">{{ result.summary.documents_visible }}</div><div class="stat-label">Из последних 100 документов</div></div></div>
</div>
<div class="section-header ax-panel-header permission-simulator-header">
<div class="section-icon"><i class="ti ti-layout-sidebar"></i></div>
<div><div class="section-eyebrow">Панель</div><h2>Разделы</h2><p class="section-desc">Что появится в левом меню и почему пункт доступен или скрыт.</p></div>
</div>
<div class="card ax-list-card">
<div class="table-scroll">
<table class="table permission-sections-table">
<thead><tr><th>Раздел</th><th>Группа</th><th>Требование</th><th>Результат</th></tr></thead>
<tbody>
{% for item in result.sections %}
<tr class="{{ item.allowed ? '' : 'is-denied' }}">
<td><span class="permission-section-name"><i class="{{ item.icon ?: 'ti ti-point' }}"></i><b>{{ item.label }}</b></span></td>
<td>{{ item.group }}</td>
<td class="mono text-sm">{{ item.permission ?: 'без отдельного права' }}</td>
<td><span class="permission-result {{ item.allowed ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ item.allowed ? 'ti-check' : 'ti-x' }}"></i>{{ item.allowed ? 'Доступен' : 'Запрещён' }}</span><small>{{ item.reason }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="section-header ax-panel-header permission-simulator-header">
<div class="section-icon"><i class="ti ti-key"></i></div>
<div><div class="section-eyebrow">Панель</div><h2>Действия</h2><p class="section-desc">Все зарегистрированные права сгруппированы по разделам.</p></div>
</div>
<div class="permission-action-groups">
{% for group in result.actions %}
<details class="card permission-action-group">
<summary><span><i class="ti ti-package"></i><b>{{ group.label }}</b></span><span>{{ group.allowed_count }} из {{ group.items|length }}</span><i class="ti ti-chevron-down"></i></summary>
<div>
{% for item in group.items %}
<div class="permission-action-row {{ item.allowed ? '' : 'is-denied' }}">
<span class="permission-result {{ item.allowed ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ item.allowed ? 'ti-check' : 'ti-x' }}"></i></span>
<div><b>{{ item.name }}</b><small>{{ item.description ?: item.reason }}</small></div>
<span class="mono">{{ item.code }}</span>
</div>
{% endfor %}
</div>
</details>
{% endfor %}
</div>
<div class="section-header ax-panel-header permission-simulator-header">
<div class="section-icon"><i class="ti ti-forms"></i></div>
<div><div class="section-eyebrow">Публичная часть</div><h2>Рубрики и документы</h2><p class="section-desc">Права публичной группы проверяются отдельно от прав панели.</p></div>
</div>
<div class="card ax-list-card">
<div class="table-scroll">
<table class="table permission-rubrics-table">
<thead><tr><th>Рубрика</th><th>Документов</th><th>Чтение</th><th>Создание</th><th>Редактирование</th><th>Причина</th></tr></thead>
<tbody>
{% for rubric in result.rubrics %}
<tr class="{{ rubric.public_read ? '' : 'is-denied' }}">
<td><a href="{{ ADMINX_BASE }}/rubrics/{{ rubric.Id }}"><b>{{ rubric.rubric_title }}</b></a><small class="mono">#{{ rubric.Id }} · {{ rubric.rubric_alias }}</small></td>
<td>{{ rubric.document_count }}</td>
<td><span class="permission-result {{ rubric.public_read ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ rubric.public_read ? 'ti-check' : 'ti-x' }}"></i></span></td>
<td><span class="permission-result {{ rubric.public_create ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ rubric.public_create ? 'ti-check' : 'ti-x' }}"></i></span></td>
<td><span class="permission-result {{ rubric.public_edit ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ rubric.public_edit ? 'ti-check' : 'ti-x' }}"></i></span></td>
<td><span>{{ rubric.reason }}</span><small class="mono">{{ rubric.permissions|join(' · ') ?: 'права не назначены' }}</small></td>
</tr>
{% else %}<tr><td colspan="6" class="ax-empty">Рубрики не найдены</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<details class="card permission-documents-sample">
<summary><span><i class="ti ti-files"></i><b>Последние 100 документов</b></span><span>Показать проверку прямого доступа</span><i class="ti ti-chevron-down"></i></summary>
<div class="table-scroll">
<table class="table">
<thead><tr><th>Документ</th><th>Рубрика</th><th>Панель</th><th>Публичная часть</th><th>Причина</th></tr></thead>
<tbody>
{% for document in result.documents %}
<tr class="{{ document.public_read ? '' : 'is-denied' }}">
<td><a href="{{ ADMINX_BASE }}/documents/{{ document.Id }}/edit"><b>{{ document.document_title }}</b></a><small class="mono">#{{ document.Id }}</small></td>
<td>{{ document.rubric_title ?: ('#' ~ document.rubric_id) }}</td>
<td><span class="permission-result {{ document.panel_view ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ document.panel_view ? 'ti-check' : 'ti-x' }}"></i>{{ document.panel_edit ? 'Просмотр и изменение' : (document.panel_view ? 'Только просмотр' : 'Нет доступа') }}</span></td>
<td><span class="permission-result {{ document.public_read ? 'is-allowed' : 'is-denied' }}"><i class="ti {{ document.public_read ? 'ti-check' : 'ti-x' }}"></i>{{ document.public_read ? 'Доступен' : 'Недоступен' }}</span></td>
<td>{{ document.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endblock %}
+203 -2
View File
@@ -20,11 +20,15 @@
use App\Common\Auth;
use App\Common\Controller as BaseController;
use App\Common\Permission;
use App\Adminx\Support\SavedViews;
use App\Content\Documents\DocumentMediaDraft;
use App\Helpers\Request;
class Controller extends BaseController
{
protected function savedViewFields() { return array('dir', 'q', 'type', 'view'); }
protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_media') ? null : $this->error('Недостаточно прав', array(), 403); }
public function index(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50);
@@ -54,9 +58,88 @@
'total' => $listing['total'],
'per_page' => $perPage,
'can_manage' => Permission::check('manage_media'),
'saved_views' => SavedViews::all('media', Auth::id(), $this->savedViewFields()),
));
}
public function saveSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
$filters = json_decode(Request::postStr('filters', '{}'), true);
if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); }
try { $views = SavedViews::save('media', Auth::id(), Request::postStr('title', ''), $filters, $this->savedViewFields()); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('Представление сохранено', array('data' => array('views' => $views)));
}
public function deleteSavedView(array $params = array())
{
if (($error = $this->savedViewGuard()) !== null) { return $error; }
try { $views = SavedViews::delete('media', Auth::id(), isset($params['id']) ? $params['id'] : '', $this->savedViewFields()); }
catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); }
return $this->success('Представление удалено', array('data' => array('views' => $views)));
}
public function audit(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50);
AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50);
$report = MediaAudit::report();
$kind = Request::getStr('kind', 'unused');
$allowedKinds = array('unused', 'duplicates', 'missing_originals', 'missing_previews', 'large', 'invalid', 'files');
if (!in_array($kind, $allowedKinds, true)) {
$kind = 'unused';
}
$query = trim(Request::getStr('q', ''));
$page = max(1, Request::getInt('page', 1));
$perPage = 100;
$rows = $report && isset($report[$kind]) && is_array($report[$kind]) ? $report[$kind] : array();
if ($query !== '') {
$needle = function_exists('mb_strtolower') ? mb_strtolower($query) : strtolower($query);
$rows = array_values(array_filter($rows, function ($row) use ($needle) {
$haystack = isset($row['path']) ? (string) $row['path'] : json_encode($row);
$haystack = function_exists('mb_strtolower') ? mb_strtolower($haystack) : strtolower($haystack);
return strpos($haystack, $needle) !== false;
}));
}
$total = count($rows);
$pages = max(1, (int) ceil($total / $perPage));
$page = min($page, $pages);
$rows = array_slice($rows, ($page - 1) * $perPage, $perPage);
return $this->render('@media/audit.twig', array(
'title' => 'Анализатор медиа',
'report' => $report,
'kind' => $kind,
'rows' => $rows,
'filters' => array('q' => $query),
'total' => $total,
'page' => $page,
'pages' => $pages,
'can_manage' => Permission::check('manage_media'),
));
}
public function runAudit(array $params = array())
{
if (($err = $this->guard()) !== null) {
return $err;
}
try {
$report = MediaAudit::run();
return $this->success('Анализ медиа завершён', array(
'data' => array('summary' => $report['summary']),
'redirect' => $this->base() . '/media/audit',
));
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
}
public function file(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50);
@@ -73,6 +156,19 @@
'parent_dir' => Model::parentDir($file['path']),
'can_manage' => Permission::check('manage_media'),
'supports_webp' => Model::supportsWebp(),
'presets' => ImagePresets::options(),
'usage' => MediaUsageIndex::usage($file['path']),
));
}
public function trash(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50);
AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50);
return $this->render('@media/trash.twig', array(
'title' => 'Корзина медиа',
'items' => MediaTrash::all(),
'can_manage' => Permission::check('manage_media'),
));
}
@@ -214,16 +310,73 @@
$path = Request::postStr('path', '');
$parent = Model::parentDir($path);
try {
Model::delete($path);
$usage = MediaAudit::inspectUsage($path);
if ((int) $usage['use_count'] > 0 && Request::postInt('confirm_usage', 0) !== 1) {
throw new \RuntimeException('Объект используется. Подтвердите перенос в корзину после просмотра связей');
}
Model::trash($path);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), array(), 422);
}
return $this->success('Удалено', array(
return $this->success('Перемещено в корзину', array(
'redirect' => $this->base() . '/media?dir=' . rawurlencode($parent ?: Model::ROOT),
));
}
public function deleteCheck(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
$path = Request::postStr('path', '');
if (Model::isProtectedPath($path)) { return $this->error('Этот путь нельзя удалить', array(), 422); }
try { $usage = MediaAudit::inspectUsage($path); }
catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('', array('data' => array('usage' => $usage)));
}
public function emptyFolder(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
$path = Request::postStr('path', '');
try {
$usage = MediaAudit::inspectUsage($path);
if ((int) $usage['use_count'] > 0 && Request::postInt('confirm_usage', 0) !== 1) {
throw new \RuntimeException('В папке есть используемые файлы. Подтвердите очистку после просмотра предупреждения');
}
$entry = Model::trashContents($path);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), array(), 422);
}
return $this->success('Содержимое папки перемещено в корзину', array(
'data' => array('count' => isset($entry['count']) ? (int) $entry['count'] : 0),
'redirect' => $this->base() . '/media?dir=' . rawurlencode($path),
));
}
public function restoreTrash(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
try {
$entry = MediaTrash::restore(isset($params['token']) ? $params['token'] : '');
foreach ($entry['paths'] as $path) { MediaSearchIndex::indexPath($path); }
}
catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('Объект восстановлен', array('redirect' => $this->base() . '/media/trash'));
}
public function purgeTrash(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
try { MediaTrash::purge(isset($params['token']) ? $params['token'] : ''); }
catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); }
return $this->success('Объект удалён окончательно', array('redirect' => $this->base() . '/media/trash'));
}
public function clearThumbnails(array $params = array())
{
if (($err = $this->guard()) !== null) {
@@ -261,6 +414,7 @@
'height' => Request::postInt('height', 240),
'format' => Request::postStr('format', 'original'),
'quality' => Request::postInt('quality', 82),
'webp_twin' => Request::postInt('webp_twin', 0),
'crop_enabled' => Request::postStr('crop_enabled', ''),
'crop_x' => Request::postInt('crop_x', 0),
'crop_y' => Request::postInt('crop_y', 0),
@@ -339,6 +493,53 @@
));
}
public function presets(array $params = array())
{
if (!Permission::check('view_media')) {
return $this->error('Недостаточно прав', array(), 403);
}
return $this->success('', array('data' => array(
'items' => ImagePresets::all(),
'modes' => ImagePresets::modes(),
'formats' => ImagePresets::formats(),
'can_manage' => Permission::check('manage_media'),
)));
}
public function storePreset(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
list($errors, $data) = ImagePresets::validate(Request::postAll(), 0);
if (!empty($errors)) { return $this->error('Проверьте поля', $errors, 422); }
$id = ImagePresets::save(0, $data);
return $this->success('Вид создан', array('data' => array('id' => $id)));
}
public function updatePreset(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
$id = isset($params['id']) ? (int) $params['id'] : 0;
if (!ImagePresets::one($id)) { return $this->error('Вид не найден', array(), 404); }
list($errors, $data) = ImagePresets::validate(Request::postAll(), $id);
if (!empty($errors)) { return $this->error('Проверьте поля', $errors, 422); }
ImagePresets::save($id, $data);
return $this->success('Вид сохранён', array('data' => array('id' => $id)));
}
public function deletePreset(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
if (!ImagePresets::delete(isset($params['id']) ? (int) $params['id'] : 0)) {
return $this->error('Вид не найден', array(), 404);
}
return $this->success('Вид удалён');
}
protected function guard()
{
return $this->guardPermission('manage_media');
@@ -0,0 +1,34 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/GlobalSearchProvider.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
class GlobalSearchProvider
{
public static function search($query, $limit = 8)
{
$out = array();
foreach (MediaSearchIndex::search($query, $limit) as $row) {
$out[] = array(
'type' => 'media', 'group' => 'Медиа', 'title' => (string) $row['name'],
'subtitle' => (string) $row['path'], 'url' => '/media/file?path=' . rawurlencode((string) $row['path']),
'icon' => !empty($row['is_image']) ? 'ti ti-photo' : 'ti ti-file', 'score' => (float) $row['score'],
);
}
return $out;
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/ImagePresets.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Content\ContentTables;
use DB;
/**
* Справочник «видов изображений»: именованные наборы параметров обрезки
* (режим + размер + формат + качество), которые подставляются в редактор
* медиа и привязываются к полям-картинкам рубрик.
*/
class ImagePresets
{
const MAX_SIDE = 2400;
public static function table()
{
return ContentTables::table('media_image_presets');
}
public static function available()
{
try { return DatabaseSchema::tableExists(self::table()); }
catch (\Throwable $e) { return false; }
}
public static function modes()
{
return array(
'cover' => 'Обрезать под размер',
'contain' => 'Вписать целиком',
'fit' => 'Уменьшить (без обрезки)',
'stretch' => 'Растянуть',
);
}
public static function formats()
{
return array(
'original' => 'Как есть',
'jpg' => 'JPG',
'png' => 'PNG',
'webp' => 'WebP',
);
}
public static function all()
{
if (!self::available()) { return array(); }
$rows = DB::query(
'SELECT * FROM ' . self::table() . ' ORDER BY sort_order ASC, title ASC, id ASC'
)->getAll() ?: array();
return array_map(array(__CLASS__, 'format'), $rows);
}
public static function one($id)
{
if (!self::available()) { return null; }
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc();
return $row ? self::format($row) : null;
}
public static function byCode($code)
{
$code = self::slug($code);
if ($code === '' || !self::available()) { return null; }
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE code=%s LIMIT 1', $code)->getAssoc();
return $row ? self::format($row) : null;
}
public static function count()
{
if (!self::available()) { return 0; }
return (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue();
}
/**
* Проверить и нормализовать вход. Возвращает [errors, data].
*/
public static function validate(array $input, $id = 0)
{
$errors = array();
$title = trim((string) (isset($input['title']) ? $input['title'] : ''));
if ($title === '') { $errors['title'] = 'Укажите название'; }
$code = self::slug(isset($input['code']) && trim((string) $input['code']) !== '' ? $input['code'] : $title);
if ($code === '') { $errors['code'] = 'Не удалось построить код из названия'; }
$mode = (string) (isset($input['mode']) ? $input['mode'] : 'cover');
if (!array_key_exists($mode, self::modes())) { $errors['mode'] = 'Неизвестный режим'; }
$format = (string) (isset($input['format']) ? $input['format'] : 'original');
if (!array_key_exists($format, self::formats())) { $errors['format'] = 'Неизвестный формат'; }
$width = (int) (isset($input['width']) ? $input['width'] : 0);
$height = (int) (isset($input['height']) ? $input['height'] : 0);
foreach (array('width' => $width, 'height' => $height) as $key => $value) {
if ($value < 0 || $value > self::MAX_SIDE) {
$errors[$key] = 'Размер от 0 до ' . self::MAX_SIDE;
}
}
// «Вписать/растянуть/обрезать» требуют обе стороны, «уменьшить» — хотя бы одну.
if ($mode === 'fit') {
if ($width < 1 && $height < 1) { $errors['width'] = 'Для «уменьшить» задайте ширину или высоту'; }
} elseif ($width < 1 || $height < 1) {
$errors['width'] = 'Задайте ширину и высоту';
}
$quality = (int) (isset($input['quality']) ? $input['quality'] : 82);
if ($quality < 1 || $quality > 100) { $errors['quality'] = 'Качество 1100'; }
if (empty($errors) && self::codeExists($code, (int) $id)) {
$errors['code'] = 'Вид с таким кодом уже есть';
}
return array($errors, array(
'code' => $code,
'title' => mb_substr($title, 0, 190, 'UTF-8'),
'group_label' => mb_substr(trim((string) (isset($input['group_label']) ? $input['group_label'] : '')), 0, 120, 'UTF-8'),
'mode' => $mode,
'width' => $width,
'height' => $height,
'format' => $format,
'quality' => $quality,
'webp_twin' => empty($input['webp_twin']) ? 0 : 1,
'sort_order' => max(0, min(9999, (int) (isset($input['sort_order']) ? $input['sort_order'] : 100))),
));
}
public static function save($id, array $data)
{
$id = (int) $id;
$now = time();
if ($id > 0) {
$data['updated_at'] = $now;
DB::Update(self::table(), $data, 'id=%i', $id);
return $id;
}
$data['created_at'] = $now;
$data['updated_at'] = $now;
DB::Insert(self::table(), $data);
return (int) DB::insertId();
}
public static function delete($id)
{
if (!self::available()) { return false; }
$exists = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE id=%i', (int) $id)->getValue();
if ($exists < 1) { return false; }
DB::Delete(self::table(), 'id=%i', (int) $id);
return true;
}
/** Опции для выпадающих списков (редактор, настройки поля). */
public static function options()
{
$out = array();
foreach (self::all() as $preset) {
$out[] = array(
'code' => $preset['code'],
'title' => $preset['title'],
'group_label' => $preset['group_label'],
'mode' => $preset['mode'],
'width' => $preset['width'],
'height' => $preset['height'],
'format' => $preset['format'],
'quality' => $preset['quality'],
'webp_twin' => $preset['webp_twin'],
'label' => $preset['label'],
);
}
return $out;
}
protected static function codeExists($code, $exceptId = 0)
{
if (!self::available()) { return false; }
return (bool) DB::query(
'SELECT id FROM ' . self::table() . ' WHERE code=%s AND id!=%i LIMIT 1',
self::slug($code),
(int) $exceptId
)->getValue();
}
protected static function format(array $row)
{
$modes = self::modes();
$width = (int) (isset($row['width']) ? $row['width'] : 0);
$height = (int) (isset($row['height']) ? $row['height'] : 0);
$mode = (string) (isset($row['mode']) ? $row['mode'] : 'cover');
$size = $width && $height ? ($width . '×' . $height) : ($width ? ($width . '×авто') : ($height ? ('авто×' . $height) : 'авто'));
return array(
'id' => (int) $row['id'],
'code' => (string) $row['code'],
'title' => (string) $row['title'],
'group_label' => (string) (isset($row['group_label']) ? $row['group_label'] : ''),
'mode' => $mode,
'mode_label' => isset($modes[$mode]) ? $modes[$mode] : $mode,
'width' => $width,
'height' => $height,
'format' => (string) (isset($row['format']) ? $row['format'] : 'original'),
'quality' => (int) (isset($row['quality']) ? $row['quality'] : 82),
'webp_twin' => (int) (isset($row['webp_twin']) ? $row['webp_twin'] : 1),
'sort_order' => (int) (isset($row['sort_order']) ? $row['sort_order'] : 100),
'label' => $row['title'] . ' · ' . $size,
);
}
protected static function slug($value)
{
$value = mb_strtolower(trim((string) $value), 'UTF-8');
$map = array(
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e','ж'=>'zh','з'=>'z','и'=>'i','й'=>'y',
'к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u','ф'=>'f',
'х'=>'h','ц'=>'c','ч'=>'ch','ш'=>'sh','щ'=>'sch','ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu','я'=>'ya',
);
$value = strtr($value, $map);
$value = preg_replace('/[^a-z0-9]+/', '-', $value);
return substr(trim((string) $value, '-'), 0, 64);
}
}
+686
View File
@@ -0,0 +1,686 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/MediaAudit.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Common\SystemTables;
use App\Content\CatalogTables;
use App\Content\ContentTables;
use App\Helpers\Dir;
use App\Helpers\File;
use App\Helpers\Number;
use App\Frontend\Media\ThumbnailUrl;
use DB;
/**
* Read-only inventory of uploads and references to them.
*
* An unused file is only a review candidate: a project may build its path
* dynamically. The analyzer never removes or rewrites files.
*/
class MediaAudit
{
const REPORT_VERSION = 1;
const LARGE_FILE_BYTES = 5242880;
const LARGE_IMAGE_SIDE = 4000;
const MAX_SOURCE_FILE_BYTES = 2097152;
protected static $imageExtensions = array('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp');
public static function report()
{
$path = self::reportPath();
if (!is_file($path)) {
return null;
}
$data = json_decode((string) File::getContent($path), true);
return is_array($data) && isset($data['version']) && (int) $data['version'] === self::REPORT_VERSION
? $data
: null;
}
/** Read current references for one file or folder without trusting a saved report. */
public static function inspectUsage($path, $limit = 50)
{
$rows = self::inspectUsageMany(array($path), $limit);
if (!$rows) {
throw new \RuntimeException('Файл или папка не найдены');
}
return reset($rows);
}
/** Read current references for several files in one database/source pass. */
public static function inspectUsageMany(array $paths, $limit = 50, $includeHistorical = true)
{
$targets = array();
foreach ($paths as $path) {
$path = Model::normalize($path, '');
$absolute = $path !== '' ? Model::abs($path) : '';
if ($path === '' || (!is_file($absolute) && !is_dir($absolute))) { continue; }
$targets[$path] = array(
'is_directory' => is_dir($absolute),
'uses' => array(),
'seen' => array(),
'count' => 0,
);
}
if (!$targets) { return array(); }
$references = self::databaseReferences((bool) $includeHistorical);
self::sourceReferences($references);
$limit = max(1, (int) $limit);
foreach ($references as $reference) {
$candidate = self::normalizeReference(isset($reference['path']) ? $reference['path'] : '');
if ($candidate === '') { continue; }
$reference['path'] = $candidate;
$key = sha1(json_encode($reference));
foreach ($targets as $path => &$target) {
if (!self::referenceMatches($candidate, $path, $target['is_directory']) || isset($target['seen'][$key])) { continue; }
$target['seen'][$key] = true;
$target['count']++;
if (count($target['uses']) < $limit) { $target['uses'][] = $reference; }
}
unset($target);
}
$checkedAt = time();
$result = array();
foreach ($targets as $path => $target) {
$result[$path] = array(
'checked' => true,
'live' => true,
'path' => $path,
'use_count' => (int) $target['count'],
'uses' => $target['uses'],
'report' => array(
'available' => true,
'generated_at' => $checkedAt,
'generated_label' => date('d.m.Y H:i:s', $checkedAt),
),
);
}
return $result;
}
public static function run()
{
$started = microtime(true);
$inventory = self::inventory();
$references = self::databaseReferences();
self::sourceReferences($references);
$usage = array();
$invalid = array();
foreach ($references as $reference) {
$normalized = self::normalizeReference($reference['path']);
if ($normalized === '') {
$reference['reason'] = 'Путь содержит недопустимые сегменты или не относится к uploads';
$invalid[] = $reference;
continue;
}
$reference['path'] = $normalized;
if (!isset($usage[$normalized])) {
$usage[$normalized] = array();
}
$usage[$normalized][] = $reference;
}
self::propagateCompanionUsage($usage, $inventory['files']);
$missingOriginals = array();
foreach ($usage as $path => $items) {
if (!isset($inventory['files'][$path])) {
$missingOriginals[] = array(
'path' => $path,
'uses' => $items,
'use_count' => count($items),
);
}
}
foreach ($inventory['orphan_previews'] as $preview) {
$missingOriginals[] = array(
'path' => $preview['original'],
'preview' => $preview['path'],
'uses' => array(),
'use_count' => 0,
);
}
$unused = array();
$missingPreviews = array();
$large = array();
$files = array();
foreach ($inventory['files'] as $path => $file) {
$file['uses'] = isset($usage[$path]) ? $usage[$path] : array();
$file['use_count'] = count($file['uses']);
$files[] = $file;
if ($file['use_count'] === 0 && !self::reservedFile($path)) {
$unused[] = $file;
}
if ($file['is_image'] && empty($inventory['preview_counts'][$path])) {
$missingPreviews[] = $file;
}
if ($file['size'] >= self::LARGE_FILE_BYTES
|| ($file['is_image'] && max($file['width'], $file['height']) >= self::LARGE_IMAGE_SIDE)) {
$large[] = $file;
}
}
usort($unused, array(__CLASS__, 'sortBySize'));
usort($large, array(__CLASS__, 'sortBySize'));
usort($missingOriginals, array(__CLASS__, 'sortByPath'));
usort($invalid, array(__CLASS__, 'sortByPath'));
usort($files, array(__CLASS__, 'sortByPath'));
$duplicates = self::duplicates($inventory['files']);
$duplicateBytes = 0;
foreach ($duplicates as $group) {
$duplicateBytes += (int) $group['recoverable_bytes'];
}
$report = array(
'version' => self::REPORT_VERSION,
'generated_at' => time(),
'generated_label' => date('d.m.Y H:i:s'),
'duration' => round(microtime(true) - $started, 2),
'thresholds' => array(
'large_file' => Number::formatSize(self::LARGE_FILE_BYTES),
'large_side' => self::LARGE_IMAGE_SIDE . ' px',
),
'summary' => array(
'files' => count($inventory['files']),
'images' => $inventory['images'],
'previews' => $inventory['previews'],
'references' => count($references),
'used_files' => count($inventory['files']) - count($unused),
'unused' => count($unused),
'duplicate_groups' => count($duplicates),
'duplicate_bytes' => $duplicateBytes,
'duplicate_size' => Number::formatSize($duplicateBytes),
'missing_originals' => count($missingOriginals),
'missing_previews' => count($missingPreviews),
'large' => count($large),
'invalid' => count($invalid),
),
'unused' => $unused,
'duplicates' => $duplicates,
'missing_originals' => $missingOriginals,
'missing_previews' => $missingPreviews,
'large' => $large,
'invalid' => $invalid,
'files' => $files,
);
MediaUsageIndex::rebuild($files, $report['generated_at']);
MediaSearchIndex::rebuild($files);
$directory = dirname(self::reportPath());
if (!Dir::create($directory)
|| !File::putAtomic(
self::reportPath(),
json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
)) {
throw new \RuntimeException('Не удалось сохранить отчёт анализатора медиа');
}
return $report;
}
protected static function inventory()
{
$result = array(
'files' => array(),
'preview_counts' => array(),
'orphan_previews' => array(),
'images' => 0,
'previews' => 0,
);
$root = rtrim(BASEPATH, '/\\') . '/uploads';
if (!is_dir($root)) {
return $result;
}
$thumbnailDirectory = ThumbnailUrl::directoryName();
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
$previewRows = array();
foreach ($iterator as $item) {
if (!$item->isFile() || $item->isLink()) {
continue;
}
$absolute = str_replace('\\', '/', $item->getPathname());
$relative = '/uploads/' . ltrim(substr($absolute, strlen(str_replace('\\', '/', $root))), '/');
if (self::hiddenPath($relative)) {
continue;
}
if (strpos('/' . trim($relative, '/') . '/', '/' . $thumbnailDirectory . '/') !== false) {
$result['previews']++;
$previewRows[] = array(
'path' => $relative,
'original' => self::previewOriginal($relative, $thumbnailDirectory),
);
continue;
}
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
$isImage = in_array($extension, self::$imageExtensions, true);
$width = 0;
$height = 0;
if ($isImage) {
$dimensions = @getimagesize($absolute);
if (is_array($dimensions)) {
$width = (int) $dimensions[0];
$height = (int) $dimensions[1];
}
$result['images']++;
}
$size = (int) $item->getSize();
$result['files'][$relative] = array(
'path' => $relative,
'name' => basename($relative),
'extension' => $extension,
'is_image' => $isImage,
'size' => $size,
'size_label' => Number::formatSize($size),
'width' => $width,
'height' => $height,
'dimensions' => $width > 0 && $height > 0 ? $width . ' × ' . $height : '',
'modified_at' => (int) $item->getMTime(),
'modified_label' => date('d.m.Y H:i', (int) $item->getMTime()),
);
}
foreach ($previewRows as $preview) {
if ($preview['original'] !== '' && isset($result['files'][$preview['original']])) {
if (!isset($result['preview_counts'][$preview['original']])) {
$result['preview_counts'][$preview['original']] = 0;
}
$result['preview_counts'][$preview['original']]++;
continue;
}
$result['orphan_previews'][] = $preview;
}
return $result;
}
protected static function databaseReferences($includeHistorical = true)
{
$sources = array(
array('table' => ContentTables::table('document_fields'), 'id' => 'Id', 'columns' => array('field_value'), 'label' => 'Поле документа', 'link' => '/documents/{document_id}/edit', 'select' => array('document_id')),
array('table' => ContentTables::table('document_fields_text'), 'id' => 'Id', 'columns' => array('field_value'), 'label' => 'Текстовое поле документа', 'link' => '/documents/{document_id}/edit', 'select' => array('document_id')),
array('table' => ContentTables::table('documents'), 'id' => 'Id', 'columns' => array('document_excerpt', 'document_meta_description', 'document_property', 'module_catalog'), 'label' => 'Документ', 'link' => '/documents/{Id}/edit'),
array('table' => ContentTables::table('rubric_fields'), 'id' => 'Id', 'columns' => array('rubric_field_default', 'rubric_field_settings', 'rubric_field_template', 'rubric_field_template_request', 'rubric_field_description'), 'label' => 'Поле рубрики', 'link' => '/rubrics/fields/{Id}'),
array('table' => ContentTables::table('rubrics'), 'id' => 'Id', 'columns' => array('rubric_template', 'rubric_start_code', 'rubric_code_start', 'rubric_code_end', 'rubric_teaser_template', 'rubric_header_template', 'rubric_og_template', 'rubric_footer_template', 'rubric_description'), 'label' => 'Рубрика', 'link' => '/rubrics/{Id}'),
array('table' => ContentTables::table('rubric_templates'), 'id' => 'id', 'columns' => array('template'), 'label' => 'Шаблон рубрики', 'link' => '/rubrics/templates/{id}'),
array('table' => ContentTables::table('templates'), 'id' => 'Id', 'columns' => array('template_text'), 'label' => 'Шаблон сайта', 'link' => '/templates/{Id}'),
array('table' => ContentTables::table('sysblocks'), 'id' => 'id', 'columns' => array('sysblock_text', 'sysblock_description'), 'label' => 'Блок', 'link' => '/blocks/{id}'),
array('table' => ContentTables::table('navigation'), 'id' => 'navigation_id', 'columns' => array('level1', 'level2', 'level3', 'level1_active', 'level2_active', 'level3_active', 'level1_begin', 'level1_end', 'level2_begin', 'level2_end', 'level3_begin', 'level3_end', 'begin', 'end'), 'label' => 'Шаблон навигации', 'link' => '/navigation/{navigation_id}'),
array('table' => ContentTables::table('navigation_items'), 'id' => 'navigation_item_id', 'columns' => array('alias', 'description', 'image', 'css_style'), 'label' => 'Пункт навигации', 'link' => '/navigation/items/{navigation_item_id}'),
array('table' => ContentTables::table('request'), 'id' => 'Id', 'columns' => array('request_template_item', 'request_template_main', 'request_description', 'request_result_contract'), 'label' => 'Запрос', 'link' => '/requests/{Id}'),
array('table' => SystemTables::table('settings'), 'id' => 'param', 'columns' => array('value'), 'label' => 'Настройка', 'link' => '/settings'),
array('table' => CatalogTables::table('catalog_product_index'), 'id' => 'product_id', 'columns' => array('image', 'images_json', 'card_fields_json'), 'label' => 'Товарный индекс', 'link' => '/catalog/products/{product_id}'),
);
if ($includeHistorical) {
$sources[] = array('table' => ContentTables::table('document_rev'), 'id' => 'Id', 'columns' => array('doc_data'), 'label' => 'Ревизия документа', 'link' => '/documents/{doc_id}/edit', 'select' => array('doc_id'));
}
$result = array();
foreach ($sources as $source) {
if (!DatabaseSchema::tableExists($source['table'])
|| !DatabaseSchema::columnExists($source['table'], $source['id'])) {
continue;
}
$columns = array();
foreach ($source['columns'] as $column) {
if (DatabaseSchema::columnExists($source['table'], $column)) {
$columns[] = $column;
}
}
if (!$columns) {
continue;
}
self::scanDatabaseSource($source, $columns, $result);
}
self::scanCombinedDocumentFields($result);
return $result;
}
/** Long legacy field values are split between the regular and text tables. */
protected static function scanCombinedDocumentFields(array &$result)
{
$fields = ContentTables::table('document_fields');
$text = ContentTables::table('document_fields_text');
if (!DatabaseSchema::tableExists($fields) || !DatabaseSchema::tableExists($text)) {
return;
}
$cursor = 0;
do {
$rows = DB::query(
'SELECT f.Id,f.document_id,f.rubric_field_id,'
. ' CONCAT(f.field_value,COALESCE(t.field_value,\'\')) field_value'
. ' FROM ' . $fields . ' f LEFT JOIN ' . $text . ' t'
. ' ON t.document_id=f.document_id AND t.rubric_field_id=f.rubric_field_id'
. ' WHERE f.Id>%i AND (LOCATE(\'uploads/\',f.field_value)>0 OR LOCATE(\'uploads/\',t.field_value)>0)'
. ' ORDER BY f.Id ASC LIMIT 500',
$cursor
)->getAll();
foreach ($rows ?: array() as $row) {
$cursor = max($cursor, (int) $row['Id']);
foreach (self::extractPaths($row['field_value']) as $path) {
$result[] = array(
'path' => $path,
'source' => 'Полное значение поля документа',
'source_id' => (string) $row['Id'],
'column' => 'field_value',
'link' => '/documents/' . (int) $row['document_id'] . '/edit',
);
}
}
} while (count($rows ?: array()) === 500);
}
/** Generated WebP/JPEG/PNG twins follow the usage of the referenced source. */
protected static function propagateCompanionUsage(array &$usage, array $files)
{
$stems = array();
foreach ($files as $path => $file) {
if (empty($file['is_image'])) { continue; }
$stem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME));
$stems[$stem][] = $path;
}
foreach (array_keys($usage) as $path) {
$stem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME));
if (empty($stems[$stem])) { continue; }
foreach ($stems[$stem] as $companion) {
if (!isset($usage[$companion])) {
$usage[$companion] = $usage[$path];
}
}
}
}
protected static function scanDatabaseSource(array $source, array $columns, array &$result)
{
$cursor = 0;
$numericId = $source['id'] !== 'param';
$select = array_unique(array_merge(array($source['id']), isset($source['select']) ? $source['select'] : array(), $columns));
$quoted = array();
foreach ($select as $column) {
if (DatabaseSchema::columnExists($source['table'], $column)) {
$quoted[] = '`' . $column . '`';
}
}
$conditions = array();
foreach ($columns as $column) {
$conditions[] = "LOCATE('/uploads/', `" . $column . '`) > 0';
$conditions[] = "LOCATE('uploads/', `" . $column . '`) > 0';
}
do {
$sql = 'SELECT ' . implode(',', $quoted) . ' FROM `' . $source['table'] . '` WHERE ';
if ($numericId) {
$rows = DB::query(
$sql . '`' . $source['id'] . '` > %i AND (' . implode(' OR ', $conditions) . ')'
. ' ORDER BY `' . $source['id'] . '` ASC LIMIT 500',
$cursor
)->getAll();
} else {
$rows = DB::query(
$sql . '(' . implode(' OR ', $conditions) . ')'
. ' ORDER BY `' . $source['id'] . '` ASC LIMIT 5000'
)->getAll();
}
foreach ($rows ?: array() as $row) {
$row = (array) $row;
if ($numericId) {
$cursor = max($cursor, (int) $row[$source['id']]);
}
$link = self::replaceTokens($source['link'], $row);
foreach ($columns as $column) {
foreach (self::extractPaths(isset($row[$column]) ? $row[$column] : '') as $path) {
$result[] = array(
'path' => $path,
'source' => $source['label'],
'source_id' => (string) $row[$source['id']],
'column' => $column,
'link' => $link,
);
}
}
}
} while ($numericId && count($rows ?: array()) === 500);
}
protected static function sourceReferences(array &$result)
{
foreach (array('templates', 'modules') as $rootName) {
$root = rtrim(BASEPATH, '/\\') . '/' . $rootName;
if (!is_dir($root)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveCallbackFilterIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
function ($item) {
return !$item->isLink() && $item->getFilename() !== 'vendor';
}
),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $item) {
if (!$item->isFile() || $item->getSize() > self::MAX_SOURCE_FILE_BYTES
|| !preg_match('/\.(?:php|inc|twig|html?|css|less|js|json)$/i', $item->getFilename())) {
continue;
}
$content = File::getContent($item->getPathname());
foreach (self::extractPaths($content) as $path) {
$relative = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen(BASEPATH))), '/');
$result[] = array(
'path' => $path,
'source' => $rootName === 'templates' ? 'Файл темы' : 'Файл модуля',
'source_id' => 0,
'column' => $relative,
'link' => '',
);
}
}
}
}
protected static function duplicates(array $files)
{
$bySize = array();
foreach ($files as $file) {
if ($file['size'] <= 0) {
continue;
}
$bySize[$file['size']][] = $file;
}
$groups = array();
foreach ($bySize as $size => $candidates) {
if (count($candidates) < 2) {
continue;
}
$byHash = array();
foreach ($candidates as $file) {
$absolute = rtrim(BASEPATH, '/\\') . $file['path'];
$hash = is_file($absolute) ? hash_file('sha256', $absolute) : '';
if ($hash !== '') {
$byHash[$hash][] = $file;
}
}
foreach ($byHash as $hash => $duplicates) {
if (count($duplicates) < 2) {
continue;
}
$groups[] = array(
'hash' => $hash,
'hash_short' => substr($hash, 0, 12),
'size' => (int) $size,
'size_label' => Number::formatSize((int) $size),
'count' => count($duplicates),
'recoverable_bytes' => ((int) $size) * (count($duplicates) - 1),
'recoverable_label' => Number::formatSize(((int) $size) * (count($duplicates) - 1)),
'files' => $duplicates,
);
}
}
usort($groups, function ($left, $right) {
return $right['recoverable_bytes'] - $left['recoverable_bytes'];
});
return $groups;
}
protected static function extractPaths($value)
{
$value = html_entity_decode(str_replace('\/', '/', (string) $value), ENT_QUOTES, 'UTF-8');
if ($value === '' || stripos($value, 'uploads/') === false) {
return array();
}
preg_match_all(
'~(?:https?://[^\s"\'<>]+)?(/?uploads/[^\s"\'<>?#\)\]\},;|\\\\]+)~iu',
$value,
$matches
);
$result = array();
foreach (isset($matches[1]) ? $matches[1] : array() as $path) {
$path = '/' . ltrim(rawurldecode((string) $path), '/');
$result[$path] = $path;
}
return array_values($result);
}
protected static function normalizeReference($path)
{
$path = str_replace('\\', '/', trim((string) $path));
$path = preg_replace('#/+#', '/', $path);
if ($path === '' || strpos($path, "\0") !== false || strpos($path, '/../') !== false
|| strpos($path, '/uploads/') !== 0) {
return '';
}
return $path;
}
protected static function referenceMatches($candidate, $path, $isDirectory)
{
if ($candidate === $path) { return true; }
if ($isDirectory) { return strpos($candidate, rtrim($path, '/') . '/') === 0; }
$candidateExtension = strtolower(pathinfo($candidate, PATHINFO_EXTENSION));
$pathExtension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($candidateExtension, self::$imageExtensions, true)
|| !in_array($pathExtension, self::$imageExtensions, true)) {
return false;
}
$candidateStem = strtolower(dirname($candidate) . '/' . pathinfo($candidate, PATHINFO_FILENAME));
$pathStem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME));
return $candidateStem === $pathStem;
}
protected static function previewOriginal($path, $directory)
{
$path = str_replace('/' . trim($directory, '/') . '/', '/', (string) $path);
$extension = pathinfo($path, PATHINFO_EXTENSION);
$filename = pathinfo($path, PATHINFO_FILENAME);
$filename = preg_replace('/-[rcfts]\d+x\d+r?$/i', '', $filename);
return dirname($path) . '/' . $filename . ($extension !== '' ? '.' . $extension : '');
}
protected static function hiddenPath($path)
{
$basename = strtolower(basename((string) $path));
return $basename === 'index.php' || $basename === '.htaccess'
|| strpos((string) $path, '/.drafts/') !== false
|| strpos((string) $path, '/.quarantine/') !== false
|| strpos((string) $path, '/.temp/') !== false;
}
protected static function reservedFile($path)
{
return preg_match('#^/uploads/(?:images/)?(?:noimage|placeholder)\.[a-z0-9]+$#i', (string) $path) === 1;
}
protected static function replaceTokens($template, array $row)
{
foreach ($row as $key => $value) {
$template = str_replace('{' . $key . '}', rawurlencode((string) $value), $template);
}
return $template;
}
protected static function reportPath()
{
return rtrim(BASEPATH, '/\\') . '/storage/reports/media-audit.json';
}
protected static function sortBySize($left, $right)
{
if ((int) $left['size'] === (int) $right['size']) {
return strcmp((string) $left['path'], (string) $right['path']);
}
return (int) $left['size'] < (int) $right['size'] ? 1 : -1;
}
protected static function sortByPath($left, $right)
{
return strcmp((string) $left['path'], (string) $right['path']);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/MediaSearchIndex.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Content\ContentTables;
use DB;
class MediaSearchIndex
{
public static function table() { return ContentTables::table('media_search_index'); }
public static function rebuild(array $files)
{
if (!DatabaseSchema::tableExists(self::table())) { return 0; }
DB::query('DELETE FROM ' . self::table());
$count = 0;
foreach ($files as $file) { self::put($file); $count++; }
return $count;
}
public static function put(array $file)
{
if (!DatabaseSchema::tableExists(self::table()) || empty($file['path'])) { return false; }
$path = (string) $file['path'];
DB::query(
'INSERT INTO ' . self::table() . ' (path_hash,path,name,extension,is_image,size,modified_at) VALUES (%s,%s,%s,%s,%i,%i,%i)'
. ' ON DUPLICATE KEY UPDATE path=VALUES(path),name=VALUES(name),extension=VALUES(extension),is_image=VALUES(is_image),size=VALUES(size),modified_at=VALUES(modified_at)',
sha1($path), $path, isset($file['name']) ? $file['name'] : basename($path), isset($file['extension']) ? $file['extension'] : pathinfo($path, PATHINFO_EXTENSION),
empty($file['is_image']) ? 0 : 1, isset($file['size']) ? (int) $file['size'] : 0, isset($file['modified_at']) ? (int) $file['modified_at'] : (isset($file['mtime']) ? (int) $file['mtime'] : time())
);
return true;
}
public static function remove($path)
{
if (!DatabaseSchema::tableExists(self::table())) { return; }
$path = Model::normalize($path, '');
if ($path === '') { return; }
$children = addcslashes(rtrim($path, '/'), '%_') . '/%';
DB::query('DELETE FROM ' . self::table() . ' WHERE path_hash=%s OR path LIKE %s', sha1($path), $children);
}
public static function indexPath($path)
{
$path = Model::normalize($path, ''); $abs = $path !== '' ? Model::abs($path) : '';
if (is_file($abs)) { $file = Model::file($path); if ($file) { self::put($file); } return; }
if (!is_dir($abs)) { return; }
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY);
$root = Model::abs(Model::ROOT);
foreach ($iterator as $item) {
if (!$item->isFile() || $item->isLink()) { continue; }
$relative = Model::ROOT . '/' . ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($root))), '/');
$file = Model::file($relative); if ($file) { self::put($file); }
}
}
public static function search($query, $limit)
{
if (!DatabaseSchema::tableExists(self::table())) { return array(); }
$query = trim((string) $query); $limit = max(1, min(20, (int) $limit));
if ($query === '') { return array(); }
return DB::query(
'SELECT path,name,extension,is_image,size,CASE WHEN LOWER(name)=LOWER(%s) THEN 100 WHEN LOWER(name) LIKE LOWER(%s) THEN 70 ELSE 30 END score'
. ' FROM ' . self::table() . ' WHERE name LIKE %ss OR path LIKE %ss ORDER BY score DESC,modified_at DESC LIMIT ' . $limit,
$query, $query . '%', $query, $query
)->getAll() ?: array();
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/MediaTrash.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\AuditLog;
use App\Common\Auth;
use App\Common\Lock;
use App\Helpers\Dir;
use App\Helpers\File;
class MediaTrash
{
public static function store($path, array $related = array())
{
return self::storePaths($path, array_merge(array($path), $related), basename($path), null);
}
public static function storeContents($path, array $paths)
{
return self::storePaths($path, $paths, basename($path) . ' — содержимое', 'folder');
}
protected static function storePaths($path, array $paths, $name, $type)
{
return Lock::run('media-trash', function () use ($path, $paths, $name, $type) {
$paths = array_values(array_unique($paths));
$token = date('Ymd-His') . '-' . substr(sha1($path . uniqid('', true)), 0, 10);
$base = self::itemsRoot() . '/' . $token;
$moved = array();
if (!Dir::create($base)) { throw new \RuntimeException('Не удалось подготовить корзину медиа'); }
try {
foreach ($paths as $sourcePath) {
$sourcePath = Model::normalize($sourcePath, '');
$source = $sourcePath !== '' ? Model::abs($sourcePath) : '';
if ($source === '' || (!is_file($source) && !is_dir($source))) { continue; }
$target = $base . '/payload/' . ltrim(substr($sourcePath, strlen(Model::ROOT)), '/');
if (!Dir::create(dirname($target)) || !@rename($source, $target)) {
throw new \RuntimeException('Не удалось переместить объект в корзину медиа');
}
$moved[] = array('path' => $sourcePath, 'stored' => $target);
}
} catch (\Throwable $e) {
self::rollbackMoves($moved);
self::removeTree($base);
throw $e;
}
if (!$moved) { self::removeTree($base); throw new \RuntimeException('Файл или папка не найдены'); }
$entry = array(
'token' => $token,
'path' => $path,
'name' => $name,
'type' => $type !== null ? $type : (is_dir($moved[0]['stored']) ? 'folder' : 'file'),
'deleted_at' => time(),
'deleted_label' => date('d.m.Y H:i:s'),
'actor_id' => (int) Auth::id(),
'paths' => array_column($moved, 'path'),
);
if (!File::putAtomic($base . '/entry.json', json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))) {
self::rollbackMoves($moved);
self::removeTree($base);
throw new \RuntimeException('Не удалось записать данные корзины медиа');
}
AuditLog::record('media.trashed', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $path, 'token' => $token)));
return $entry;
});
}
public static function all()
{
$rows = array();
foreach (glob(self::itemsRoot() . '/*/entry.json') ?: array() as $file) {
$row = json_decode((string) File::getContent($file), true);
if (is_array($row) && !empty($row['token'])) { $rows[] = $row; }
}
usort($rows, function ($left, $right) { return (int) $right['deleted_at'] - (int) $left['deleted_at']; });
return $rows;
}
public static function restore($token)
{
return Lock::run('media-trash', function () use ($token) {
$entry = self::entry($token);
foreach ($entry['paths'] as $path) {
if (file_exists(Model::abs($path))) { throw new \RuntimeException('Исходный путь уже занят: ' . $path); }
}
$moved = array();
try {
foreach ($entry['paths'] as $path) {
$stored = self::storedPath($entry['token'], $path);
$target = Model::abs($path);
if ((!is_file($stored) && !is_dir($stored)) || !Dir::create(dirname($target)) || !@rename($stored, $target)) {
throw new \RuntimeException('Не удалось восстановить: ' . $path);
}
$moved[] = array('path' => $path, 'stored' => $stored);
}
} catch (\Throwable $e) {
foreach (array_reverse($moved) as $row) { @rename(Model::abs($row['path']), $row['stored']); }
throw $e;
}
self::removeTree(self::itemsRoot() . '/' . $entry['token']);
AuditLog::record('media.restored', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $entry['path'], 'token' => $entry['token'])));
return $entry;
});
}
public static function purge($token)
{
return Lock::run('media-trash', function () use ($token) {
$entry = self::entry($token);
self::removeTree(self::itemsRoot() . '/' . $entry['token']);
AuditLog::record('media.purged', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $entry['path'], 'token' => $entry['token'])));
return $entry;
});
}
protected static function entry($token)
{
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $token);
$file = self::itemsRoot() . '/' . $token . '/entry.json';
$row = is_file($file) ? json_decode((string) File::getContent($file), true) : null;
if (!is_array($row) || empty($row['paths'])) { throw new \RuntimeException('Объект корзины не найден'); }
return $row;
}
protected static function storedPath($token, $path)
{
return self::itemsRoot() . '/' . $token . '/payload/' . ltrim(substr($path, strlen(Model::ROOT)), '/');
}
protected static function itemsRoot()
{
$root = rtrim(BASEPATH, '/\\') . '/storage/media-trash/items';
Dir::create($root);
return $root;
}
protected static function rollbackMoves(array $moved)
{
foreach (array_reverse($moved) as $row) {
Dir::create(dirname(Model::abs($row['path'])));
@rename($row['stored'], Model::abs($row['path']));
}
}
protected static function removeTree($path)
{
if (!is_dir($path)) { if (is_file($path)) { @unlink($path); } return; }
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($iterator as $item) { $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); }
@rmdir($path);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Media/MediaUsageIndex.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Media;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Lock;
use App\Helpers\Dir;
use App\Helpers\File;
/** Compact, path-addressable companion index for the large media audit report. */
class MediaUsageIndex
{
const VERSION = 1;
public static function rebuild(array $files, $generatedAt)
{
return Lock::run('media-usage-index', function () use ($files, $generatedAt) {
$root = self::root();
$building = $root . '.building-' . substr(sha1(uniqid('', true)), 0, 10);
self::removeTree($building);
if (!Dir::create($building)) {
throw new \RuntimeException('Не удалось подготовить индекс использования медиа');
}
$count = 0;
foreach ($files as $file) {
$path = isset($file['path']) ? self::normalize($file['path']) : '';
if ($path === '' || empty($file['uses'])) { continue; }
$hash = sha1($path);
$dir = $building . '/' . substr($hash, 0, 2);
if (!Dir::create($dir) || !File::putAtomic($dir . '/' . $hash . '.json', json_encode(array(
'path' => $path,
'use_count' => count($file['uses']),
'uses' => array_values($file['uses']),
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))) {
self::removeTree($building);
throw new \RuntimeException('Не удалось записать индекс использования медиа');
}
$count++;
}
File::putAtomic($building . '/meta.json', json_encode(array(
'version' => self::VERSION,
'generated_at' => (int) $generatedAt,
'generated_label' => date('d.m.Y H:i:s', (int) $generatedAt),
'used_files' => $count,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$old = $root . '.old-' . substr(sha1(uniqid('', true)), 0, 8);
if (is_dir($root) && !@rename($root, $old)) {
self::removeTree($building);
throw new \RuntimeException('Не удалось заменить индекс использования медиа');
}
if (!@rename($building, $root)) {
if (is_dir($old)) { @rename($old, $root); }
self::removeTree($building);
throw new \RuntimeException('Не удалось активировать индекс использования медиа');
}
self::removeTree($old);
return self::meta();
});
}
public static function usage($path)
{
$path = self::normalize($path);
$meta = self::meta();
if ($path === '' || !$meta['available']) {
return array('checked' => false, 'path' => $path, 'use_count' => 0, 'uses' => array(), 'report' => $meta);
}
$file = self::entryPath($path);
$data = is_file($file) ? json_decode((string) File::getContent($file), true) : array();
if (!is_array($data) || !isset($data['path']) || $data['path'] !== $path) {
$data = array('path' => $path, 'use_count' => 0, 'uses' => array());
}
$data['checked'] = true;
$data['report'] = $meta;
return $data;
}
public static function usageForPath($path, $limit = 50)
{
$path = self::normalize($path);
$abs = $path !== '' ? Model::abs($path) : '';
if ($path === '' || !is_dir($abs)) { return self::usage($path); }
$meta = self::meta();
$result = array('checked' => $meta['available'], 'path' => $path, 'use_count' => 0, 'uses' => array(), 'report' => $meta);
if (!$meta['available']) { return $result; }
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
$seen = array();
foreach ($iterator as $item) {
if (!$item->isFile() || $item->isLink()) { continue; }
$relative = '/uploads/' . ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen(Model::abs(Model::ROOT)))), '/');
$usage = self::usage($relative);
$result['use_count'] += (int) $usage['use_count'];
foreach ($usage['uses'] as $use) {
$key = md5(json_encode($use));
if (isset($seen[$key])) { continue; }
$seen[$key] = true;
if (count($result['uses']) < max(1, (int) $limit)) { $result['uses'][] = $use; }
}
}
return $result;
}
public static function meta()
{
$file = self::root() . '/meta.json';
$data = is_file($file) ? json_decode((string) File::getContent($file), true) : array();
$available = is_array($data) && isset($data['version']) && (int) $data['version'] === self::VERSION;
return array(
'available' => $available,
'generated_at' => $available && isset($data['generated_at']) ? (int) $data['generated_at'] : 0,
'generated_label' => $available && isset($data['generated_label']) ? (string) $data['generated_label'] : '',
'used_files' => $available && isset($data['used_files']) ? (int) $data['used_files'] : 0,
);
}
protected static function entryPath($path)
{
$hash = sha1($path);
return self::root() . '/' . substr($hash, 0, 2) . '/' . $hash . '.json';
}
protected static function normalize($path)
{
$path = Model::normalize($path, '');
return $path !== '' && Model::isAllowedPath($path) ? $path : '';
}
protected static function root()
{
return rtrim(BASEPATH, '/\\') . '/storage/reports/media-usage';
}
protected static function removeTree($path)
{
if (!is_dir($path)) { return; }
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($path);
}
}
+58 -19
View File
@@ -330,6 +330,7 @@
}
if ($uploaded) {
foreach ($uploaded as $file) { MediaSearchIndex::put($file); }
self::invalidateStats();
}
@@ -378,37 +379,65 @@
self::renameWebpCompanion($path, $target);
}
MediaSearchIndex::remove($path);
MediaSearchIndex::indexPath($target);
self::invalidateStats();
return $target;
}
public static function delete($path)
public static function trash($path)
{
$path = self::normalize($path, '');
if (self::isProtectedPath($path)) {
throw new \RuntimeException('Этот путь нельзя удалить');
}
if (self::isProtectedPath($path)) { throw new \RuntimeException('Этот путь нельзя удалить'); }
$abs = self::abs($path);
if (is_dir($abs)) {
if (!self::removeDir($abs)) {
throw new \RuntimeException('Не удалось удалить папку');
}
self::invalidateStats();
return true;
}
if (!is_file($abs) && !is_dir($abs)) { throw new \RuntimeException('Файл или папка не найдены'); }
$related = array();
if (is_file($abs)) {
self::deleteFileCompanions($path, $abs);
}
if (is_file($abs) && !File::delete($abs)) {
throw new \RuntimeException('Не удалось удалить файл');
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($extension, array('jpg', 'jpeg', 'png'), true)) {
$webp = dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME) . '.webp';
if (is_file(self::abs($webp))) { $related[] = $webp; }
}
}
$entry = MediaTrash::store($path, $related);
if ($entry['type'] === 'file') { self::clearGeneratedForFile($path, $abs); }
MediaSearchIndex::remove($path);
self::invalidateStats();
return true;
return $entry;
}
public static function trashContents($dir)
{
$dir = self::normalize($dir, '');
if ($dir === '' || self::isProtectedPath($dir)) {
throw new \RuntimeException('Эту папку нельзя очистить');
}
$abs = self::abs($dir);
if (!is_dir($abs)) {
throw new \RuntimeException('Папка не найдена');
}
$paths = array();
foreach (@scandir($abs) ?: array() as $name) {
if (!self::isVisibleEntry($name)) { continue; }
$paths[] = rtrim($dir, '/') . '/' . $name;
}
if (!$paths) {
throw new \RuntimeException('Папка уже пуста');
}
$entry = MediaTrash::storeContents($dir, $paths);
try { self::clearThumbnails($dir); } catch (\RuntimeException $e) { /* Производные можно пересоздать. */ }
$derivatives = $abs . DIRECTORY_SEPARATOR . '_derivatives';
if (is_dir($derivatives)) { self::removeDir($derivatives); }
MediaSearchIndex::remove($dir);
self::invalidateStats();
$entry['count'] = count($paths);
return $entry;
}
public static function clearThumbnails($dir)
@@ -468,6 +497,7 @@
throw new \RuntimeException('Не удалось удалить превью');
}
ThumbnailStorage::invalidateDirectory(dirname($target));
$roots++;
}
@@ -556,6 +586,15 @@
$name = self::uniqueName($outAbsDir, self::safeName($base, false), $r['format']);
$out = $outAbsDir . DIRECTORY_SEPARATOR . $name;
self::saveImage($dst, $out, $r['format'], $r['quality']);
// WebP-двойник рядом: если вид просит и результат ещё не webp —
// кладём тот же кадр в .webp с тем же базовым именем (foto-...-.webp),
// чтобы сайт отдавал webp с jpg-фолбэком. Делаем до imagedestroy.
if (!empty($input['webp_twin']) && $r['format'] !== 'webp' && self::supportsWebp()) {
$webpName = pathinfo($name, PATHINFO_FILENAME) . '.webp';
self::saveImage($dst, $outAbsDir . DIRECTORY_SEPARATOR . $webpName, 'webp', $r['quality']);
}
imagedestroy($dst);
return rtrim($outDir, '/') . '/' . $name;
}
+299
View File
@@ -4,6 +4,251 @@
gap: 14px;
margin-bottom: 16px;
}
.media-tabs {
margin-bottom: 18px;
}
.media-tabs .tab {
text-decoration: none;
}
.media-usage-panel {
margin-bottom: 18px;
}
.media-usage-panel .section-header {
margin-bottom: 12px;
}
.media-usage-card {
padding: 0;
}
.media-usage-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 54px;
padding: 8px 14px;
border-bottom: 1px solid var(--border-default);
}
.media-usage-row:last-child {
border-bottom: 0;
}
.media-usage-row .icon-tile {
width: 32px;
height: 32px;
background: var(--blue-100);
color: var(--blue-600);
}
.media-usage-row > div {
display: grid;
gap: 2px;
min-width: 0;
}
.media-usage-row small,
.table-subtitle {
display: block;
margin-top: 3px;
color: var(--text-muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.media-trash-card {
padding: 0;
}
.media-audit-empty {
display: flex;
align-items: center;
gap: 14px;
padding: 20px;
}
.media-audit-empty h2,
.media-audit-empty p {
margin: 0;
}
.media-audit-empty h2 {
margin-bottom: 4px;
font-size: 16px;
}
.media-audit-empty p {
max-width: 760px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
}
.media-audit-summary {
margin-bottom: 14px;
}
.media-audit-note,
.media-audit-header {
margin-bottom: 18px;
}
.media-audit-kinds {
display: grid;
grid-template-columns: repeat(7, minmax(130px, 1fr));
gap: 8px;
margin-bottom: 14px;
overflow-x: auto;
scrollbar-width: thin;
}
.media-audit-kinds a {
min-width: 130px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--background-surface);
color: var(--text-secondary);
text-decoration: none;
}
.media-audit-kinds a:hover,
.media-audit-kinds a.is-active {
border-color: var(--color-primary);
color: var(--color-primary);
}
.media-audit-kinds a.is-active {
background: var(--color-primary-soft);
}
.media-audit-kinds i {
font-size: 18px;
}
.media-audit-kinds span {
overflow: hidden;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-audit-kinds b {
color: var(--text-primary);
font-size: 13px;
}
.media-audit-card {
padding: 0;
}
.media-audit-toolbar form {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
}
.media-audit-toolbar .input-wrap {
width: min(440px, 100%);
}
.media-audit-table th:nth-child(1) {
width: 42%;
}
.media-audit-table th:nth-child(2) {
width: 160px;
}
.media-audit-table th:last-child {
width: 52px;
}
.media-audit-path,
.media-usage-list {
display: grid;
gap: 3px;
min-width: 0;
}
.media-audit-path b {
overflow-wrap: anywhere;
font-size: 13px;
}
.media-audit-path small,
.media-usage-list small,
.media-usage-list span {
color: var(--text-muted);
font-size: 11px;
}
.media-usage-list a {
width: fit-content;
max-width: 100%;
color: var(--color-primary);
font-size: 12px;
overflow-wrap: anywhere;
}
.media-duplicate-list {
display: grid;
}
.media-duplicate-group {
border-bottom: 1px solid var(--border-default);
}
.media-duplicate-group:last-child {
border-bottom: 0;
}
.media-duplicate-group summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 20px;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 10px 20px;
cursor: pointer;
list-style: none;
}
.media-duplicate-group summary::-webkit-details-marker {
display: none;
}
.media-duplicate-group summary span {
display: grid;
gap: 2px;
}
.media-duplicate-group summary small {
color: var(--text-muted);
font-size: 12px;
}
.media-duplicate-group[open] summary > i {
transform: rotate(180deg);
}
.media-duplicate-group > div {
display: grid;
padding: 0 20px 12px 52px;
}
.media-duplicate-group > div a {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
color: var(--text-primary);
}
.media-duplicate-group > div span {
overflow-wrap: anywhere;
}
.media-duplicate-group > div small {
color: var(--text-muted);
}
[data-media-audit-run].is-loading i {
animation: media-audit-spin 700ms linear infinite;
}
@keyframes media-audit-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.media-audit-toolbar form {
align-items: stretch;
flex-direction: column;
}
.media-audit-toolbar .input-wrap,
.media-audit-toolbar .btn {
width: 100%;
}
.media-audit-table th:nth-child(2) {
width: auto;
}
.media-duplicate-group summary {
grid-template-columns: minmax(0, 1fr) 20px;
}
.media-duplicate-group summary .mono {
display: none;
}
.media-duplicate-group > div {
padding-left: 20px;
}
}
.media-browser a,
.media-file-layout a,
.media-lightbox a,
@@ -737,3 +982,57 @@ button.media-thumb {
grid-template-columns: 1fr;
}
}
.media-presets-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.media-preset-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--surface-2, #f6f8fb);
}
.media-preset-row .media-preset-main {
flex: 1;
min-width: 0;
}
.media-preset-row .media-preset-main small {
color: var(--text-secondary, #667085);
display: block;
margin-top: 2px;
}
.media-presets-form {
margin-top: 16px;
}
.media-preset-pick {
margin-bottom: 12px;
}
.media-preset-pick-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.media-preset-pick-field > span {
font-size: 13px;
color: var(--text-secondary, #667085);
}
.media-preset-warn {
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: var(--amber-100, #fef3c7);
color: var(--amber-700, #b45309);
font-size: 13px;
}
.media-preset-check {
flex-direction: row;
align-items: center;
gap: 8px;
}
.media-preset-check > span {
color: var(--text-secondary, #667085);
font-size: 13px;
}
+295 -8
View File
@@ -51,18 +51,35 @@
var folder = e.target.closest('[data-media-folder]');
var rename = e.target.closest('[data-media-rename]');
var del = e.target.closest('[data-media-delete]');
var emptyFolder = e.target.closest('[data-media-empty-folder]');
var clearThumbs = e.target.closest('[data-media-clear-thumbs]');
var copy = e.target.closest('[data-copy]');
var webp = e.target.closest('[data-media-webp]');
var zoom = e.target.closest('[data-media-result-zoom]');
var audit = e.target.closest('[data-media-audit-run]');
var trashRestore = e.target.closest('[data-media-trash-restore]');
var trashPurge = e.target.closest('[data-media-trash-purge]');
if (audit) { self.runAudit(audit); return; }
if (trashRestore) { self.restoreTrash(trashRestore); return; }
if (trashPurge) { self.purgeTrash(trashPurge); return; }
if (folder) { self.createFolder(); return; }
if (rename) { self.rename(rename); return; }
if (del) { self.remove(del); return; }
if (emptyFolder) { self.emptyFolder(emptyFolder); return; }
if (clearThumbs) { self.clearThumbnails(clearThumbs); return; }
if (webp) { self.convertWebp(webp); return; }
if (zoom) { e.preventDefault(); self.openResultLarge(); return; }
if (copy) { self.copy(copy.getAttribute('data-copy')); }
if (copy) { self.copy(copy.getAttribute('data-copy')); return; }
var presetsOpen = e.target.closest('[data-media-presets-open]');
if (presetsOpen) { self.loadPresets(); return; }
if (e.target.closest('[data-preset-save]')) { self.savePreset(); return; }
if (e.target.closest('[data-preset-reset]')) { self.resetPresetForm(); return; }
var presetEdit = e.target.closest('[data-preset-edit]');
if (presetEdit) { self.editPreset(presetEdit.getAttribute('data-preset-edit')); return; }
var presetDelete = e.target.closest('[data-preset-delete]');
if (presetDelete) { self.deletePreset(presetDelete.getAttribute('data-preset-delete')); return; }
});
},
@@ -70,6 +87,142 @@
return Adminx.base();
},
runAudit: function (button) {
var form = document.querySelector('[data-media-audit-form]');
if (!form || button.disabled) { return; }
button.disabled = true;
button.classList.add('is-loading');
button.innerHTML = '<i class="ti ti-loader-2"></i>Проверяем...';
Adminx.Loader.show();
Adminx.Ajax.post(form.action, new FormData(form)).then(function (payload) {
Adminx.Loader.hide();
var result = payload.data || {};
if (!result.success) {
button.disabled = false;
button.classList.remove('is-loading');
}
Adminx.Ajax.handle(payload);
}).catch(function () {
Adminx.Loader.hide();
button.disabled = false;
button.classList.remove('is-loading');
Adminx.Toast.show('Не удалось выполнить проверку медиа', 'error');
});
},
presetData: null,
loadPresets: function () {
var self = this;
Adminx.Loader.show();
Adminx.Ajax.request(this.base() + '/media/presets').then(function (payload) {
Adminx.Loader.hide();
var r = payload.data || {};
if (!r.success || !r.data) { Adminx.Toast.show(r.message || 'Не удалось загрузить виды', 'error'); return; }
self.presetData = r.data;
self.fillPresetSelects(r.data.modes || {}, r.data.formats || {});
self.renderPresets(r.data.items || []);
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
},
fillPresetSelects: function (modes, formats) {
var m = document.querySelector('[data-preset-modes]');
var f = document.querySelector('[data-preset-formats]');
if (m && !m.getAttribute('data-filled')) {
m.innerHTML = Object.keys(modes).map(function (k) { return '<option value="' + esc(k) + '">' + esc(modes[k]) + '</option>'; }).join('');
m.setAttribute('data-filled', '1');
}
if (f && !f.getAttribute('data-filled')) {
f.innerHTML = Object.keys(formats).map(function (k) { return '<option value="' + esc(k) + '">' + esc(formats[k]) + '</option>'; }).join('');
f.setAttribute('data-filled', '1');
}
},
renderPresets: function (items) {
var list = document.querySelector('[data-preset-list]');
if (!list) { return; }
if (!items.length) { list.innerHTML = '<div class="empty-state">Виды ещё не заданы. Добавьте первый в форме ниже.</div>'; return; }
var canManage = this.presetData && this.presetData.can_manage;
list.innerHTML = items.map(function (item) {
return '<div class="media-preset-row">'
+ '<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-aspect-ratio"></i></span>'
+ '<div class="media-preset-main"><div><b>' + esc(item.title) + '</b>'
+ (item.group_label ? ' <span class="badge badge-gray">' + esc(item.group_label) + '</span>' : '') + '</div>'
+ '<small>' + esc(item.mode_label) + ' · ' + item.width + '×' + item.height + (item.format !== 'original' ? ' · ' + esc(item.format) : '') + ' · q' + item.quality + (item.webp_twin ? ' · +WebP' : '') + '</small></div>'
+ (canManage ? '<div class="cluster"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-preset-edit="' + item.id + '" data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>'
+ '<button class="btn btn-ghost btn-icon btn-sm media-action-danger" type="button" data-preset-delete="' + item.id + '" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>' : '')
+ '</div>';
}).join('');
},
editPreset: function (id) {
if (!this.presetData) { return; }
var item = (this.presetData.items || []).filter(function (p) { return String(p.id) === String(id); })[0];
var form = document.getElementById('mediaPresetForm');
if (!item || !form) { return; }
form.querySelector('[name="id"]').value = item.id;
form.querySelector('[name="title"]').value = item.title;
form.querySelector('[name="group_label"]').value = item.group_label || '';
form.querySelector('[name="mode"]').value = item.mode;
form.querySelector('[name="format"]').value = item.format;
form.querySelector('[name="width"]').value = item.width;
form.querySelector('[name="height"]').value = item.height;
form.querySelector('[name="quality"]').value = item.quality;
var twin = form.querySelector('[name="webp_twin"]');
if (twin) { twin.checked = !!item.webp_twin; }
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
},
resetPresetForm: function () {
var form = document.getElementById('mediaPresetForm');
if (!form) { return; }
form.reset();
form.querySelector('[name="id"]').value = '';
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
},
savePreset: function () {
var form = document.getElementById('mediaPresetForm');
if (!form) { return; }
var self = this;
var id = (form.querySelector('[name="id"]').value || '').trim();
var url = this.base() + '/media/presets' + (id ? '/' + id : '');
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
Adminx.Loader.show();
Adminx.Ajax.post(url, new FormData(form)).then(function (payload) {
Adminx.Loader.hide();
var r = payload.data || {};
if (!r.success) {
if (r.errors) { Object.keys(r.errors).forEach(function (k) { var el = form.querySelector('[data-error="' + k + '"]'); if (el) { el.textContent = r.errors[k]; } }); }
Adminx.Toast.show(r.message || 'Проверьте поля', 'error');
return;
}
Adminx.Toast.show(r.message || 'Сохранено', 'success');
self.resetPresetForm();
self.loadPresets();
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
},
deletePreset: function (id) {
var self = this;
Adminx.Confirm.open({
kind: 'danger',
title: 'Удалить вид?',
message: 'Он исчезнет из списка подгонки. Уже обрезанные картинки не изменятся.',
confirmLabel: 'Удалить',
onConfirm: function () {
Adminx.Loader.show();
Adminx.Ajax.post(self.base() + '/media/presets/' + id + '/delete').then(function (payload) {
Adminx.Loader.hide();
var r = payload.data || {};
if (!r.success) { Adminx.Toast.show(r.message || 'Не удалось удалить', 'error'); return; }
Adminx.Toast.show(r.message || 'Удалено', 'success');
self.loadPresets();
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
}
});
},
modalInput: function (cfg, done) {
var overlay = document.createElement('div');
overlay.className = 'overlay';
@@ -146,16 +299,104 @@
var self = this;
var name = button.getAttribute('data-name') || button.getAttribute('data-path') || '';
var kind = button.getAttribute('data-kind') === 'folder' ? 'папку и всё содержимое' : 'файл';
var form = document.getElementById('mediaActionForm');
if (!form) { return; }
var fd = new FormData(form);
fd.set('path', button.getAttribute('data-path') || '');
Adminx.Loader.show();
Adminx.Ajax.post(self.base() + '/media/delete-check', fd).then(function (payload) {
Adminx.Loader.hide();
var response = payload.data || {};
if (!response.success) {
Adminx.Toast.show(response.message || 'Не удалось проверить использование', 'error');
return;
}
var usage = response.data && response.data.usage ? response.data.usage : { use_count: 0, uses: [] };
var used = Number(usage.use_count || 0);
var message = used > 0
? '«' + name + '» используется в ' + used + ' местах. Объект будет скрыт с сайта и перенесён в корзину, откуда его можно восстановить.'
: '«' + name + '» будет перенесён в корзину и останется доступен для восстановления.';
Adminx.Confirm.open({
kind: used > 0 ? 'warning' : 'danger',
title: used > 0 ? 'Объект используется' : 'Удалить ' + kind + '?',
message: message,
confirmLabel: 'В корзину',
confirmClass: used > 0 ? 'btn-primary' : 'btn-danger',
onConfirm: function () {
submitAction(self.base() + '/media/delete', {
path: button.getAttribute('data-path') || '',
confirm_usage: used > 0 ? '1' : '0'
});
}
});
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show('Ошибка проверки медиа', 'error');
});
},
emptyFolder: function (button) {
var self = this;
var path = button.getAttribute('data-path') || '';
var name = button.getAttribute('data-name') || path;
var form = document.getElementById('mediaActionForm');
if (!form) { return; }
var fd = new FormData(form);
fd.set('path', path);
Adminx.Loader.show();
Adminx.Ajax.post(self.base() + '/media/delete-check', fd).then(function (payload) {
Adminx.Loader.hide();
var response = payload.data || {};
if (!response.success) {
Adminx.Toast.show(response.message || 'Не удалось проверить содержимое папки', 'error');
return;
}
var usage = response.data && response.data.usage ? response.data.usage : { use_count: 0 };
var used = Number(usage.use_count || 0);
Adminx.Confirm.open({
kind: used > 0 ? 'warning' : 'danger',
title: 'Очистить папку «' + name + '»?',
message: used > 0
? 'Внутри есть файлы, используемые в ' + used + ' местах. Всё содержимое будет перенесено в корзину, сама папка останется.'
: 'Все файлы и вложенные папки будут перенесены в корзину. Текущая папка останется на месте.',
confirmLabel: 'Очистить папку',
confirmClass: used > 0 ? 'btn-primary' : 'btn-danger',
onConfirm: function () {
submitAction(self.base() + '/media/empty-folder', {
path: path,
confirm_usage: used > 0 ? '1' : '0'
});
}
});
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show('Ошибка проверки медиа', 'error');
});
},
restoreTrash: function (button) {
var self = this;
Adminx.Confirm.open({
kind: 'error',
title: 'Удалить ' + kind + '?',
message: '«' + name + '» будет удалён с диска.',
confirmLabel: 'Удалить',
kind: 'info',
title: 'Восстановить объект?',
message: '«' + (button.getAttribute('data-name') || '') + '» вернётся по исходному пути.',
confirmLabel: 'Восстановить',
onConfirm: function () {
submitAction(self.base() + '/media/trash/' + encodeURIComponent(button.getAttribute('data-media-trash-restore') || '') + '/restore', {});
}
});
},
purgeTrash: function (button) {
var self = this;
Adminx.Confirm.open({
kind: 'danger',
title: 'Удалить окончательно?',
message: '«' + (button.getAttribute('data-name') || '') + '» нельзя будет восстановить.',
confirmLabel: 'Удалить навсегда',
confirmClass: 'btn-danger',
onConfirm: function () {
submitAction(self.base() + '/media/delete', {
path: button.getAttribute('data-path') || ''
});
submitAction(self.base() + '/media/trash/' + encodeURIComponent(button.getAttribute('data-media-trash-purge') || '') + '/purge', {});
}
});
},
@@ -547,6 +788,52 @@
if (ratioSelect) {
ratioSelect.addEventListener('change', function (e) { setRatio(e.target.value); });
}
var presetSelect = form.querySelector('[data-crop-preset]');
var presetWarn = form.querySelector('[data-crop-preset-warning]');
if (presetSelect) {
presetSelect.addEventListener('change', function () {
var opt = presetSelect.options[presetSelect.selectedIndex];
var webpTwinInput = form.querySelector('[data-webp-twin]');
if (!opt || !opt.value) {
if (webpTwinInput) { webpTwinInput.value = '0'; }
if (presetWarn) { presetWarn.hidden = true; }
return;
}
if (webpTwinInput) { webpTwinInput.value = opt.getAttribute('data-webp-twin') === '1' ? '1' : '0'; }
var pw = parseInt(opt.getAttribute('data-width'), 10) || 0;
var ph = parseInt(opt.getAttribute('data-height'), 10) || 0;
var pmode = opt.getAttribute('data-mode');
var pformat = opt.getAttribute('data-format');
var pquality = parseInt(opt.getAttribute('data-quality'), 10) || 82;
var modeInput = form.querySelector('[name="mode"]');
var formatInput = form.querySelector('[name="format"]');
var qualityInput = form.querySelector('[name="quality"]');
if (modeInput && pmode) { modeInput.value = pmode; }
if (formatInput && pformat && formatInput.querySelector('option[value="' + pformat + '"]')) { formatInput.value = pformat; }
if (qualityInput) { qualityInput.value = pquality; }
// рамку кропа — под соотношение вида (если заданы обе стороны)
if (pw > 0 && ph > 0) {
if (ratioSelect) { ratioSelect.value = 'free'; }
setRatio(pw + ':' + ph);
}
// выходной размер — точные размеры вида (перекрывает пересчёт из sync)
if (inputs.outputW && pw > 0) { inputs.outputW.value = pw; }
if (inputs.outputH && ph > 0) { inputs.outputH.value = ph; }
if (inputs.outputW) { inputs.outputW.dispatchEvent(new Event('input', { bubbles: true })); }
// предупреждение о растягивании
if (presetWarn) {
var nw = img.naturalWidth || 0;
var nh = img.naturalHeight || 0;
if ((pw && nw && nw < pw) || (ph && nh && nh < ph)) {
presetWarn.textContent = 'Исходник ' + nw + '×' + nh + ' меньше вида ' + pw + '×' + ph + ' — картинка растянется и будет мыльной.';
presetWarn.hidden = false;
} else {
presetWarn.hidden = true;
}
}
});
}
form.querySelector('[data-crop-reset]').addEventListener('click', function () {
state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 };
sync(true);
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_analyzer_title">Media analyzer</phrase>
<phrase data="media_analyzer_short">Analyzer</phrase>
<phrase data="media_analyzer_intro">Checks uploads, previews, and references without deleting anything automatically.</phrase>
<phrase data="media_analyzer_sections_aria">Media sections</phrase>
<phrase data="media_analyzer_type_aria">Result type</phrase>
<phrase data="media_analyzer_run_again">Run again</phrase>
<phrase data="media_analyzer_run">Run check</phrase>
<phrase data="media_analyzer_not_run">The check has not been run yet</phrase>
<phrase data="media_analyzer_empty_help">The analyzer matches physical files against documents, templates, blocks, navigation, and theme files. Nothing is deleted or changed.</phrase>
<phrase data="media_analyzer_files">Files</phrase>
<phrase data="media_analyzer_unused_label">No references found</phrase>
<phrase data="media_analyzer_duplicates_label">Duplicate groups</phrase>
<phrase data="media_analyzer_no_original">Missing original</phrase>
<phrase data="media_analyzer_report_from">Report from</phrase>
<phrase data="media_analyzer_report_help">“No references found” only means that no explicit path exists in the database or source files. The analyzer cannot recognize dynamically assembled paths.</phrase>
<phrase data="media_analyzer_results">Results</phrase>
<phrase data="media_analyzer_check_title">Files and references</phrase>
<phrase data="media_analyzer_check_help">Select an issue type, then open the file or its usage location.</phrase>
<phrase data="media_analyzer_unused">Unused</phrase>
<phrase data="media_analyzer_duplicates">Duplicates</phrase>
<phrase data="media_analyzer_missing_originals">Missing originals</phrase>
<phrase data="media_analyzer_missing_previews">Missing previews</phrase>
<phrase data="media_analyzer_large">Large files</phrase>
<phrase data="media_analyzer_invalid">Invalid paths</phrase>
<phrase data="media_analyzer_usage_map">Usage map</phrase>
<phrase data="media_analyzer_unused_title">Files with no references found</phrase>
<phrase data="media_analyzer_duplicates_title">Files with identical content</phrase>
<phrase data="media_analyzer_missing_originals_title">References and previews without originals</phrase>
<phrase data="media_analyzer_missing_previews_title">Images without generated previews</phrase>
<phrase data="media_analyzer_large_title">Large files and images</phrase>
<phrase data="media_analyzer_invalid_title">Invalid references</phrase>
<phrase data="media_analyzer_usage_title">File usage map</phrase>
<phrase data="media_analyzer_preview_help">Previews are generated on demand; this state is not an error.</phrase>
<phrase data="media_analyzer_duplicate_prefix">Potentially duplicated:</phrase>
<phrase data="media_analyzer_duplicate_data">of data.</phrase>
<phrase data="media_analyzer_threshold">Threshold:</phrase>
<phrase data="media_analyzer_or_side">or image side from</phrase>
<phrase data="media_analyzer_found">Records found:</phrase>
<phrase data="media_analyzer_path_source">Path or source</phrase>
<phrase data="media_analyzer_same_files">identical files</phrase>
<phrase data="media_analyzer_each">each · recoverable up to</phrase>
<phrase data="media_analyzer_no_duplicates">No duplicate files found</phrase>
<phrase data="media_analyzer_file_path">File or path</phrase>
<phrase data="media_analyzer_usage">Usage</phrase>
<phrase data="media_analyzer_preview">Preview:</phrase>
<phrase data="media_analyzer_manual">review manually</phrase>
<phrase data="media_analyzer_original_missing">original not found</phrase>
<phrase data="media_analyzer_not_warmed">not generated</phrase>
<phrase data="media_analyzer_invalid_path">invalid path</phrase>
<phrase data="media_analyzer_used">used</phrase>
<phrase data="media_analyzer_no_links">no references</phrase>
<phrase data="media_analyzer_more">and</phrase>
<phrase data="media_analyzer_no_explicit_links">No explicit references</phrase>
<phrase data="media_analyzer_open_file">Open file</phrase>
<phrase data="media_analyzer_no_issues">No issues found for this filter</phrase>
<phrase data="media_analyzer_running">Checking...</phrase>
<phrase data="media_analyzer_run_error">Could not complete the media check</phrase>
</language>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_usage_title">Where this file is used</phrase>
<phrase data="media_usage_description">Data from the latest media analyzer run.</phrase>
<phrase data="media_usage_not_checked">The analyzer has not run yet.</phrase>
<phrase data="media_usage_run_check">Run media check</phrase>
<phrase data="media_usage_run_check_help">to view references and enable deletion.</phrase>
<phrase data="media_usage_links">references</phrase>
<phrase data="media_usage_none">No explicit references found.</phrase>
<phrase data="media_usage_checked_at">Checked</phrase>
<phrase data="media_usage_dynamic_warning">Dynamically composed paths may not be detected.</phrase>
<phrase data="media_usage_open_source">Open source</phrase>
<phrase data="media_trash_title">Media trash</phrase>
<phrase data="media_trash_description">Files are outside the public directory and can be restored.</phrase>
<phrase data="media_trash_items">Deleted files and folders</phrase>
<phrase data="media_trash_items_help">Restore returns an item to its original path. Permanent deletion cannot be undone.</phrase>
<phrase data="media_trash_empty">Media trash is empty</phrase>
<phrase data="media_restore">Restore</phrase>
<phrase data="media_purge">Delete permanently</phrase>
<phrase data="media_to_trash">Move to trash</phrase>
<phrase data="media_usage_check_failed">Failed to check usage</phrase>
<phrase data="media_usage_error">Media check failed</phrase>
<phrase data="media_restore_confirm">Restore item?</phrase>
<phrase data="media_purge_confirm">Delete permanently?</phrase>
<phrase data="media_purge_forever">Delete forever</phrase>
</language>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_analyzer_title">Анализатор медиа</phrase>
<phrase data="media_analyzer_short">Анализатор</phrase>
<phrase data="media_analyzer_intro">Проверка файлов, превью и ссылок на uploads без автоматического удаления.</phrase>
<phrase data="media_analyzer_sections_aria">Разделы медиа</phrase>
<phrase data="media_analyzer_type_aria">Тип результата</phrase>
<phrase data="media_analyzer_run_again">Проверить заново</phrase>
<phrase data="media_analyzer_run">Запустить проверку</phrase>
<phrase data="media_analyzer_not_run">Проверка ещё не запускалась</phrase>
<phrase data="media_analyzer_empty_help">Анализатор сопоставит физические файлы с документами, шаблонами, блоками, навигацией и файлами темы. Ничего не будет удалено или изменено.</phrase>
<phrase data="media_analyzer_files">Файлов</phrase>
<phrase data="media_analyzer_unused_label">Без найденных связей</phrase>
<phrase data="media_analyzer_duplicates_label">Групп дублей</phrase>
<phrase data="media_analyzer_no_original">Нет оригинала</phrase>
<phrase data="media_analyzer_report_from">Отчёт от</phrase>
<phrase data="media_analyzer_report_help">«Без найденных связей» означает только отсутствие явного пути в базе и исходниках: динамически составленные пути анализатор распознать не может.</phrase>
<phrase data="media_analyzer_results">Результаты</phrase>
<phrase data="media_analyzer_check_title">Проверка файлов и связей</phrase>
<phrase data="media_analyzer_check_help">Выберите тип проблемы, затем откройте файл или место его использования.</phrase>
<phrase data="media_analyzer_unused">Не используются</phrase>
<phrase data="media_analyzer_duplicates">Одинаковые</phrase>
<phrase data="media_analyzer_missing_originals">Нет оригиналов</phrase>
<phrase data="media_analyzer_missing_previews">Нет превью</phrase>
<phrase data="media_analyzer_large">Большие</phrase>
<phrase data="media_analyzer_invalid">Неверные пути</phrase>
<phrase data="media_analyzer_usage_map">Карта использования</phrase>
<phrase data="media_analyzer_unused_title">Файлы без найденных связей</phrase>
<phrase data="media_analyzer_duplicates_title">Файлы с одинаковым содержимым</phrase>
<phrase data="media_analyzer_missing_originals_title">Ссылки и превью без оригинала</phrase>
<phrase data="media_analyzer_missing_previews_title">Изображения без созданных превью</phrase>
<phrase data="media_analyzer_large_title">Большие файлы и изображения</phrase>
<phrase data="media_analyzer_invalid_title">Некорректные ссылки</phrase>
<phrase data="media_analyzer_usage_title">Карта использования файлов</phrase>
<phrase data="media_analyzer_preview_help">Превью создаются по требованию; это состояние не является ошибкой.</phrase>
<phrase data="media_analyzer_duplicate_prefix">Потенциально повторяется</phrase>
<phrase data="media_analyzer_duplicate_data">данных.</phrase>
<phrase data="media_analyzer_threshold">Порог:</phrase>
<phrase data="media_analyzer_or_side">или сторона изображения от</phrase>
<phrase data="media_analyzer_found">Найдено записей:</phrase>
<phrase data="media_analyzer_path_source">Путь или источник</phrase>
<phrase data="media_analyzer_same_files">одинаковых файла</phrase>
<phrase data="media_analyzer_each">каждый · можно освободить до</phrase>
<phrase data="media_analyzer_no_duplicates">Одинаковых файлов не найдено</phrase>
<phrase data="media_analyzer_file_path">Файл или путь</phrase>
<phrase data="media_analyzer_usage">Использование</phrase>
<phrase data="media_analyzer_preview">Превью:</phrase>
<phrase data="media_analyzer_manual">проверить вручную</phrase>
<phrase data="media_analyzer_original_missing">оригинал не найден</phrase>
<phrase data="media_analyzer_not_warmed">не прогрето</phrase>
<phrase data="media_analyzer_invalid_path">неверный путь</phrase>
<phrase data="media_analyzer_used">используется</phrase>
<phrase data="media_analyzer_no_links">связей нет</phrase>
<phrase data="media_analyzer_more">и ещё</phrase>
<phrase data="media_analyzer_no_explicit_links">Явных ссылок нет</phrase>
<phrase data="media_analyzer_open_file">Открыть файл</phrase>
<phrase data="media_analyzer_no_issues">Для этого фильтра проблем не найдено</phrase>
<phrase data="media_analyzer_running">Проверяем...</phrase>
<phrase data="media_analyzer_run_error">Не удалось выполнить проверку медиа</phrase>
</language>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_usage_title">Где используется файл</phrase>
<phrase data="media_usage_description">Данные последней проверки анализатора медиа.</phrase>
<phrase data="media_usage_not_checked">Анализ ещё не запускался.</phrase>
<phrase data="media_usage_run_check">Проверьте медиа</phrase>
<phrase data="media_usage_run_check_help">чтобы увидеть связи и разрешить удаление.</phrase>
<phrase data="media_usage_links">связей</phrase>
<phrase data="media_usage_none">Явных ссылок не найдено.</phrase>
<phrase data="media_usage_checked_at">Проверка выполнена</phrase>
<phrase data="media_usage_dynamic_warning">Динамически составленные пути могут не распознаваться.</phrase>
<phrase data="media_usage_open_source">Открыть источник</phrase>
<phrase data="media_trash_title">Корзина медиа</phrase>
<phrase data="media_trash_description">Файлы находятся вне публичного каталога и могут быть восстановлены.</phrase>
<phrase data="media_trash_items">Удалённые файлы и папки</phrase>
<phrase data="media_trash_items_help">Восстановление возвращает объект по исходному пути. Окончательное удаление необратимо.</phrase>
<phrase data="media_trash_empty">Корзина медиа пуста</phrase>
<phrase data="media_restore">Восстановить</phrase>
<phrase data="media_purge">Удалить окончательно</phrase>
<phrase data="media_to_trash">В корзину</phrase>
<phrase data="media_usage_check_failed">Не удалось проверить использование</phrase>
<phrase data="media_usage_error">Ошибка проверки медиа</phrase>
<phrase data="media_restore_confirm">Восстановить объект?</phrase>
<phrase data="media_purge_confirm">Удалить окончательно?</phrase>
<phrase data="media_purge_forever">Удалить навсегда</phrase>
</language>

Some files were not shown because too many files have changed in this diff Show More