Author SHA1 Message Date
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
MadDen 997ffe08eb Restore runtime directory guards 2026-07-30 11:57:36 +03:00
MadDen af9abc0047 AVE.cms 3.3 build 0.35 2026-07-30 11:56:32 +03:00
476 changed files with 33289 additions and 3306 deletions
+9
View File
@@ -5,9 +5,11 @@
/.vscode/
/.claude/
/.agents/
/.playwright-mcp/
# Node dependencies
/node_modules/
/modules/mcp/transport/node_modules/
# Composer dev dependencies (тесты). composer.json/composer.lock — в гите.
/vendor/
@@ -20,9 +22,16 @@
/tmp/
/uploads/
/storage/backups/
/storage/importprice/
/storage/imports/
/storage/jobs/
/storage/logs/
/storage/media-trash/
/storage/releases/
/storage/reports/
/storage/runtime/
/storage/secrets/
/storage/tmp/
/storage/updates/
/storage/installed.lock
/sample/
+620 -336
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.32** — модульная система управления сайтами на PHP.
**AVE.cms 3.3 build 0.37** — модульная система управления сайтами на PHP.
Она подходит для проектов, в которых структуру содержимого нужно собирать из
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
привязываясь к заранее заданному типу сайта.
+91
View File
@@ -0,0 +1,91 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/BulkActionExecutor.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\ErrorReport;
/**
* Единый безопасный цикл массовых административных действий.
*
* Контроллер по-прежнему отвечает за CSRF, права и аудит, а executor
* нормализует ID, ограничивает размер пачки и изолирует ошибку одной записи.
*/
class BulkActionExecutor
{
public static function execute($action, $rawIds, array $actions, $limit = 200)
{
$action = trim((string) $action);
if ($action === '' || empty($actions[$action]) || !is_callable($actions[$action])) {
throw new \InvalidArgumentException('Неизвестное массовое действие');
}
$ids = self::normalizeIds($rawIds, $limit);
$result = array(
'action' => $action,
'requested' => count($ids),
'done' => 0,
'skipped' => 0,
'errors' => array(),
);
foreach ($ids as $id) {
try {
$outcome = call_user_func($actions[$action], $id);
if ($outcome === false || (is_array($outcome) && isset($outcome['done']) && !$outcome['done'])) {
$result['skipped']++;
continue;
}
$result['done']++;
} catch (\Throwable $e) {
$result['errors'][] = '#' . $id . ': ' . ErrorReport::publicMessage(
'Не удалось обработать запись',
$e,
'BULK',
array('action' => $action, 'id' => $id)
);
}
}
return $result;
}
public static function normalizeIds($rawIds, $limit = 200)
{
$limit = max(1, min(1000, (int) $limit));
if (is_string($rawIds)) {
$rawIds = trim($rawIds);
$decoded = $rawIds !== '' && $rawIds[0] === '[' ? json_decode($rawIds, true) : null;
$rawIds = is_array($decoded) ? $decoded : preg_split('/[\s,;]+/', $rawIds, -1, PREG_SPLIT_NO_EMPTY);
}
$ids = array();
foreach (is_array($rawIds) ? $rawIds : array() as $rawId) {
$id = (int) $rawId;
if ($id > 0) {
$ids[$id] = $id;
}
}
$ids = array_values($ids);
if (!$ids || count($ids) > $limit) {
throw new \InvalidArgumentException('Выберите от 1 до ' . $limit . ' записей');
}
return $ids;
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/GlobalSearchRegistry.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\Permission;
use App\Common\Language;
/**
* Реестр провайдеров административного поиска.
*
* Provider получает ($query, $limit) и возвращает список безопасных
* admin-relative результатов. Ошибка одного provider не ломает палитру.
*/
class GlobalSearchRegistry
{
protected static $providers = array();
protected static $reported = array();
public static function register($code, array $definition)
{
$code = self::code($code);
$provider = isset($definition['provider']) ? $definition['provider'] : null;
if ($code === '' || !is_callable($provider)) {
return false;
}
self::$providers[$code] = array(
'code' => $code,
'provider' => $provider,
'permission' => isset($definition['permission']) ? trim((string) $definition['permission']) : '',
'priority' => isset($definition['priority']) ? (int) $definition['priority'] : 100,
'limit' => max(1, min(30, isset($definition['limit']) ? (int) $definition['limit'] : 8)),
);
return true;
}
public static function search($query, $limit = 30)
{
$query = trim(preg_replace('/\s+/u', ' ', (string) $query));
$limit = max(1, min(50, (int) $limit));
if (mb_strlen($query, 'UTF-8') < 2) {
return array();
}
$providers = array_values(self::$providers);
usort($providers, function ($left, $right) {
$order = (int) $left['priority'] <=> (int) $right['priority'];
return $order !== 0 ? $order : strnatcasecmp((string) $left['code'], (string) $right['code']);
});
$items = array();
foreach ($providers as $providerOrder => $definition) {
if ($definition['permission'] !== '' && !Permission::check($definition['permission'])) {
continue;
}
try {
$rows = call_user_func(
$definition['provider'],
$query,
min($definition['limit'], $limit)
);
} catch (\Throwable $e) {
self::report($definition['code'], $e);
continue;
}
$position = 0;
foreach (is_array($rows) ? $rows : array() as $row) {
$item = self::normalize($row, $definition['code']);
if (!$item) {
continue;
}
$item['_provider_order'] = $providerOrder;
$item['_position'] = $position++;
$items[] = $item;
}
}
usort($items, function ($left, $right) {
$provider = (int) $left['_provider_order'] <=> (int) $right['_provider_order'];
if ($provider !== 0) {
return $provider;
}
$score = (float) $right['score'] <=> (float) $left['score'];
return $score !== 0 ? $score : ((int) $left['_position'] <=> (int) $right['_position']);
});
$items = array_slice($items, 0, $limit);
foreach ($items as &$item) {
unset($item['_provider_order'], $item['_position'], $item['score']);
}
unset($item);
return $items;
}
public static function providers()
{
return array_keys(self::$providers);
}
public static function resetRuntime()
{
self::$providers = array();
self::$reported = array();
}
protected static function normalize($row, $providerCode)
{
if (!is_array($row)) {
return null;
}
$title = self::text(isset($row['title']) ? $row['title'] : '', 190);
$url = self::localUrl(isset($row['url']) ? $row['url'] : '');
if ($title === '' || $url === '') {
return null;
}
$icon = trim((string) (isset($row['icon']) ? $row['icon'] : 'ti ti-search'));
if (!preg_match('/^ti(?:\s+ti-[a-z0-9-]+)+$/', $icon)) {
$icon = 'ti ti-search';
}
return array(
'type' => self::code(isset($row['type']) ? $row['type'] : $providerCode),
'group' => Language::translateSource(self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80)),
'title' => $title,
'subtitle' => self::text(isset($row['subtitle']) ? $row['subtitle'] : '', 300),
'url' => $url,
'icon' => $icon,
'score' => isset($row['score']) && is_numeric($row['score']) ? (float) $row['score'] : 0.0,
);
}
protected static function localUrl($url)
{
$url = trim((string) $url);
if ($url === ''
|| $url[0] !== '/'
|| strpos($url, '//') === 0
|| strpos($url, '\\') !== false
|| preg_match('/[\r\n\x00-\x1f]/', $url)) {
return '';
}
return substr($url, 0, 1000);
}
protected static function code($value)
{
$value = strtolower(trim((string) $value));
return substr(preg_replace('/[^a-z0-9_.-]+/', '_', $value), 0, 64);
}
protected static function text($value, $limit)
{
$value = html_entity_decode(strip_tags((string) $value), ENT_QUOTES, 'UTF-8');
$value = trim(preg_replace('/\s+/u', ' ', $value));
return mb_substr($value, 0, (int) $limit, 'UTF-8');
}
protected static function report($code, \Throwable $e)
{
if (isset(self::$reported[$code])) {
return;
}
self::$reported[$code] = true;
error_log('Global search provider ' . $code . ': ' . $e->getMessage());
}
}
+70
View File
@@ -133,6 +133,10 @@
return strcmp((string) $a['layout_code'], (string) $b['layout_code']);
});
if ($scope === 'navigation' && $config) {
$items = self::mergeNewNavigationItems($items);
}
foreach ($items as &$item) {
unset($item['_layout_position']);
}
@@ -148,6 +152,72 @@
}));
}
/**
* Новые пункты, которых ещё нет в сохранённой раскладке, остаются внутри
* своей группы по штатному sort_order, а не уходят в конец всего меню.
*/
protected static function mergeNewNavigationItems(array $items)
{
$configured = array();
$added = array();
foreach ($items as $item) {
if ($item['_layout_position'] === null) {
$added[] = $item;
} else {
$configured[] = $item;
}
}
usort($added, array(__CLASS__, 'compareDefaultOrder'));
foreach ($added as $item) {
$insertAt = count($configured);
$lastPeer = null;
foreach ($configured as $index => $peer) {
if (!self::navigationPeers($item, $peer)) {
continue;
}
$lastPeer = $index;
if (self::compareDefaultOrder($item, $peer) < 0) {
$insertAt = $index;
break;
}
$insertAt = $index + 1;
}
if ($lastPeer === null) {
$insertAt = count($configured);
}
array_splice($configured, $insertAt, 0, array($item));
}
return $configured;
}
protected static function navigationPeers(array $left, array $right)
{
$leftParent = isset($left['parent']) ? (string) $left['parent'] : '';
$rightParent = isset($right['parent']) ? (string) $right['parent'] : '';
if ($leftParent !== '' || $rightParent !== '') {
return $leftParent !== '' && $leftParent === $rightParent;
}
return (string) $left['group'] === (string) $right['group'];
}
protected static function compareDefaultOrder(array $left, array $right)
{
$leftOrder = isset($left['sort_order']) ? (int) $left['sort_order'] : 1000;
$rightOrder = isset($right['sort_order']) ? (int) $right['sort_order'] : 1000;
if ($leftOrder !== $rightOrder) {
return $leftOrder < $rightOrder ? -1 : 1;
}
return strcmp((string) $left['layout_code'], (string) $right['layout_code']);
}
protected static function navigationHierarchy(array $items)
{
$roots = array();
+120
View File
@@ -51,6 +51,7 @@
);
if (!empty($module['installed']) && !empty($module['enabled'])) {
self::registerMenu($module['admin_extension']);
self::registerSearch($module['code'], $module['admin_extension']);
}
}
@@ -168,6 +169,64 @@
return $items;
}
/**
* Missing hard and recommended dependencies for the module owning a route.
*/
public static function dependencyNotices($path)
{
$module = self::moduleForPath($path);
if (!$module) {
return array();
}
$descriptor = ModuleManager::descriptor($module['code']);
$required = is_array($descriptor) && isset($descriptor['requires'])
? self::dependencyDefinitions($descriptor['requires'])
: array();
$recommended = is_array($descriptor) && isset($descriptor['recommends'])
? self::dependencyDefinitions($descriptor['recommends'])
: array();
$requiredCodes = array();
$recommendedReasons = array();
foreach ($required as $definition) {
$requiredCodes[] = $definition['code'];
}
foreach ($recommended as $definition) {
$recommendedReasons[$definition['code']] = $definition['reason'];
}
$notices = array();
foreach (array(
'required' => $requiredCodes,
'recommended' => array_keys($recommendedReasons),
) as $level => $codes) {
foreach ($codes as $code) {
$dependency = ModuleManager::get($code);
if ($dependency && !empty($dependency['installed']) && !empty($dependency['enabled'])) {
continue;
}
$name = $dependency && !empty($dependency['name']) ? (string) $dependency['name'] : (string) $code;
$state = !$dependency ? 'missing' : (empty($dependency['installed']) ? 'available' : 'disabled');
$reason = $level === 'recommended' && isset($recommendedReasons[$code])
? $recommendedReasons[$code]
: '';
$notices[] = array(
'level' => $level,
'code' => (string) $code,
'name' => $name,
'state' => $state,
'reason' => $reason,
'action' => $state === 'disabled' ? 'Включить модуль' : ($state === 'missing' ? 'Найти в каталоге' : 'Установить модуль'),
'url' => '/modules?' . ($state === 'missing' ? 'tab=catalog&' : '') . 'focus=' . rawurlencode((string) $code),
);
}
}
return $notices;
}
protected static function contributions($type, array $visibleCodes = null)
{
self::boot();
@@ -250,6 +309,24 @@
}
}
protected static function registerSearch($moduleCode, array $config)
{
if (empty($config['search'])) {
return;
}
foreach (self::normalizeItems($config['search']) as $index => $definition) {
if (!is_array($definition)) {
continue;
}
$code = isset($definition['code']) && trim((string) $definition['code']) !== ''
? (string) $definition['code']
: (string) ((int) $index + 1);
GlobalSearchRegistry::register($moduleCode . '.' . $code, $definition);
}
}
protected static function normalizeItems($items)
{
if (!is_array($items)) {
@@ -285,6 +362,49 @@
return $fallback;
}
protected static function moduleForPath($path)
{
$path = '/' . trim((string) $path, '/');
$match = null;
$matchLength = -1;
foreach (ModuleManager::all() as $module) {
$extension = isset($module['admin_extension']) && is_array($module['admin_extension'])
? $module['admin_extension']
: array();
$url = isset($extension['url']) ? '/' . trim((string) $extension['url'], '/') : '';
if ($url === '' || ($path !== $url && strpos($path, $url . '/') !== 0) || strlen($url) <= $matchLength) {
continue;
}
$match = $module;
$matchLength = strlen($url);
}
return $match;
}
protected static function dependencyDefinitions($dependencies)
{
$result = array();
foreach (is_array($dependencies) ? $dependencies : array() as $dependency) {
$code = is_array($dependency) && isset($dependency['code'])
? strtolower(trim((string) $dependency['code']))
: strtolower(trim((string) $dependency));
if ($code === '') {
continue;
}
$result[] = array(
'code' => $code,
'reason' => is_array($dependency) && isset($dependency['reason'])
? trim((string) $dependency['reason'])
: '',
);
}
return $result;
}
/** Управляемая администратором видимость UI-вклада модуля. */
protected static function presentationEnabled($code, $type)
{
+22 -9
View File
@@ -80,6 +80,11 @@
self::item('#^/documents/views$#', 'Статистика просмотров', 'Показывает посещаемость документов по дням и помогает находить наиболее востребованные страницы.', array('Период влияет только на подневную статистику.', 'Общий счётчик документа хранится отдельно и не очищается вместе с дневными данными.', 'Очистка статистики необратима и предназначена для обслуживания.'), 'Для бизнес-аналитики используйте внешнюю систему аналитики; здесь хранится внутренняя статистика CMS.'),
self::item('#^/documents/(?:create|\d+/edit)$#', 'Редактор документа', 'Создаёт и изменяет страницу сайта: её адрес, публикацию, SEO и значения полей выбранной рубрики.', array('Набор полей и их группы задаются рубрикой.', 'Изменение alias может автоматически создать редирект со старого URL.', 'Ctrl+S сохраняет изменения и оставляет редактор открытым; ревизии позволяют вернуться к прошлой версии.'), 'Смена рубрики существующего документа запрещена без отдельной миграции полей.'),
self::item('#^/documents#', 'Документы', 'Основной реестр страниц и товарных карточек сайта, построенных на рубриках и их полях.', array('Фильтруйте документы по рубрике, состоянию и названию.', 'Публикацию, копирование и удаление можно выполнять из списка или пакетно.', 'Удалённый документ сначала попадает в корзину и только затем может быть удалён окончательно.'), 'Документ №1 и системная страница 404 защищены от окончательного удаления.', 'content/documents'),
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('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'),
self::item('#^/catalog/products(?:/.*)?$#', 'Товары каталога', 'Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории.', array('Переиндексация обновляет проекцию товара из полей документа.', 'Редактор товара остаётся редактором документа, но открывается из каталога.', 'Фильтры списка не меняют данные товара.'), 'После изменения каталоговых полей индекс обычно обновляется автоматически.'),
@@ -87,9 +92,12 @@
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'),
self::item('#^/rubrics/field-sets$#', 'Библиотека наборов полей', 'Добавляет в рубрику готовую структуру полей для типовой задачи.', array('Копия создаёт независимые поля.', 'Связанный режим позволяет позже синхронизировать набор вручную.', 'Перед применением проверяются существующие alias.'), 'После применения настройте порядок и ширину полей в конструкторе рубрики.', 'content/content-model-tools'),
self::item('#^/directories(?:/.*)?$#', 'Справочники', 'Управляет повторно используемыми значениями через обычные рубрики и документы.', array('Сначала создайте справочник и его поля.', 'Затем наполните его документами-значениями.', 'В рабочей рубрике выберите справочник источником поля связи.'), 'Справочник использует штатную модель документов и не создаёт параллельное хранилище.', 'content/content-model-tools'),
self::item('#^/rubrics(?:/.*)?$#', 'Рубрики и поля', 'Определяет типы документов: набор полей, группы, шаблоны вывода, права и код жизненного цикла.', array('Поля являются отдельными сущностями со своим типом, настройками и шаблоном.', 'Группы организуют длинные формы документов и не влияют на публичный URL.', 'Код до/после сохранения выполняется при изменении документа и требует проверки синтаксиса.'), 'Удаляйте рубрики и поля только после проверки связанных документов и шаблонов.', 'content/rubrics'),
self::item('#^/themes#', 'Темы публичного сайта', 'Управляет файлами оформления, подключениями CSS/JavaScript и способом сборки публичной страницы.', array('Файлы темы находятся в templates/<код темы> и проходят проверку пути и расширения.', 'Во вкладке «Настройки» выбирается нативная сборка AVE.cms или Twig-оболочка темы.', 'Текстовые файлы получают ревизии; тему можно экспортировать или импортировать безопасным ZIP-пакетом.'), 'Ассеты и переопределения темы работают в обоих режимах сборки.', 'content/themes'),
self::item('#^/templates#', 'Шаблоны страниц', 'Хранит внешнюю HTML/PHP-оболочку сайта, в которую подставляется содержимое документа и системные теги.', array('Шаблон №1 является системным и не удаляется.', 'Сохранение обновляет БД, файловый кеш и создаёт ревизию.', 'Палитра тегов вставляет зарегистрированные блоки, навигации и значения страницы.'), 'Ошибка PHP в основном шаблоне может нарушить весь публичный сайт — используйте проверку перед сохранением.', 'content/templates'),
@@ -97,23 +105,28 @@
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.', 'Виджеты установленных модулей появляются в списке автоматически.'), 'Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню.'),
self::item('#^/settings/security$#', 'Подтверждение критических действий', 'Управляет повторным запросом пароля перед изменением исполняемого кода и установкой модулей.', array('Подтверждение действует пять минут для текущего пользователя и IP-адреса.', 'Отключение повторного пароля не отменяет проверку прав роли и CSRF-токена.', 'Настройка распространяется на блоки, шаблоны, рубрики, запросы, навигацию и другие операции с кодом.'), 'По умолчанию подтверждение выключено; на рабочем сайте его можно включить как дополнительный защитный слой.'),
self::item('#^/settings/constants$#', 'Системные константы', 'Хранит типизированные значения конфигурации, доступные ядру и публичному сайту.', array('Тип определяет допустимый редактор и формат сохранённого значения.', 'Перед добавлением проверьте, нет ли уже настройки с тем же назначением.', 'Удаляйте константу только после поиска её использования в коде и шаблонах.'), 'Название константы является программным контрактом и не должно меняться без миграции.'),
self::item('#^/settings/paginations$#', 'Шаблоны пагинации', 'Настраивает HTML постраничной навигации для документов и сохранённых запросов.', array('Базовый шаблон защищён от удаления.', 'Теги страниц, текущего состояния и ссылок подставляются во время рендера.', 'После изменения проверьте первую, среднюю и последнюю страницы списка.'), 'Шаблон должен сохранять доступность ссылок и понятное обозначение текущей страницы.'),
self::item('#^/settings/maintenance$#', 'Обслуживание', 'Очищает адресные кеши и накопленные служебные данные AVE.cms.', array('Каждая кнопка работает только со своим источником данных.', 'Очистка кеша не удаляет документы и настройки.', 'Ревизии и статистика просмотров удаляются необратимо.'), 'Перед очисткой данных, которые нельзя восстановить из документов, создайте резервную копию.'),
self::item('#^/settings/maintenance$#', 'Обслуживание', 'Очищает адресные кеши и накопленные служебные данные AVE.cms.', array('Каждая кнопка работает только со своим источником данных.', 'Очистка кеша не удаляет документы и настройки.', 'Очистка ревизий удаляет сохранённые версии документов, блоков, шаблонов, рубрик, тем, представлений, карточек товаров и фильтров каталога.', 'Аудит действий, история заказов и обращения этой операцией не затрагиваются.'), 'Ревизии и статистика просмотров удаляются необратимо — перед очисткой создайте резервную копию.'),
self::item('#^/settings/diagnostics$#', 'Диагностика системы', 'Проверяет PHP, расширения, доступ к каталогам, почту, данные модулей и готовность публичной части.', array('Проверки не отправляют письма и не выполняют внешние операции.', 'Красные состояния нужно устранить до публикации.', 'Тяжёлые проверки могут кратковременно кешироваться.'), 'Диагностика ядра не заменяет отдельную проверку подключённых модулей и внешних сервисов.'),
self::item('#^/settings/files$#', 'Системные файлы', 'Даёт контролируемый редактор разрешённых PHP-файлов конфигурации и пользовательских функций.', array('Список файлов ограничен системой и не является файловым менеджером.', 'Перед сохранением выполняется проверка PHP-синтаксиса.', 'Ошибка в таком файле может повлиять и на панель, и на публичный сайт.'), 'Используйте этот раздел для небольших проектных дополнений; крупную функциональность оформляйте модулем.'),
self::item('#^/settings/benchmark$#', 'Производительность', 'Измеряет скорость PHP, базы данных, файловых операций и кеша на текущем сервере.', array('Результаты нужны для сравнения одного окружения во времени, а не разных проектов.', 'Повторяйте тест после обновления PHP, БД или инфраструктуры.', 'Тест создаёт кратковременную нагрузку и сохраняет историю запусков.'), 'Оценивайте не только общий балл, но и конкретный медленный этап.'),
@@ -121,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);
}
}
+197 -11
View File
@@ -520,7 +520,6 @@ hr {
background: var(--color-danger);
border-radius: 999px;
border: 2px solid var(--background-page);
font-variant-numeric: tabular-nums;
}
.notif-menu {
width: 330px;
@@ -579,7 +578,6 @@ hr {
font-weight: 700;
font-size: 13px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.notif-empty {
padding: 30px 16px;
@@ -594,6 +592,13 @@ hr {
color: var(--color-success);
}
/* Заголовок в меню пользователя */
#userMenu > .dropdown-menu {
width: 280px;
max-width: calc(100vw - 16px);
}
#userMenu #themeToggle [data-theme-label] {
white-space: nowrap;
}
.dd-user {
display: flex;
align-items: center;
@@ -1805,7 +1810,6 @@ hr {
}
.ax-list-head > .badge {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
@media (max-width: 680px) {
.ax-list-head {
@@ -2652,6 +2656,132 @@ input[type="color"].color-input {
right: auto;
}
}
.command-palette {
position: fixed;
z-index: 1300;
inset: 0;
}
.command-palette-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(2px);
}
.command-palette-dialog {
position: relative;
width: min(680px, calc(100vw - 32px));
max-height: min(620px, calc(100vh - 96px));
margin: 72px auto 0;
overflow: hidden;
border-radius: 8px;
background: var(--background-surface, #fff);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28), 0 4px 18px rgba(15, 23, 42, 0.14);
}
.command-palette-input {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
min-height: 62px;
padding: 0 var(--space-3) 0 var(--space-5);
border-bottom: 1px solid var(--border-default);
background: inherit;
}
.command-palette-input > .ti {
color: var(--text-secondary);
font-size: 20px;
}
.command-palette-input input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 16px;
}
.command-palette-input input:focus,
.command-palette-input input:focus-visible {
outline: 0;
box-shadow: none;
}
.command-palette-input input::-webkit-search-cancel-button {
display: none;
-webkit-appearance: none;
appearance: none;
}
.command-palette-results {
max-height: min(550px, calc(100vh - 160px));
overflow-y: auto;
padding: var(--space-2);
background: inherit;
}
.command-palette-group {
padding: var(--space-2) var(--space-3) var(--space-1);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.command-palette-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 52px;
padding: var(--space-2) var(--space-3);
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.command-palette-item:hover,
.command-palette-item.is-active {
background: var(--blue-50);
color: var(--blue-700);
}
.command-palette-item .icon-tile {
width: 36px;
height: 36px;
}
.command-palette-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.command-palette-copy b,
.command-palette-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-copy small {
color: var(--text-secondary);
}
.command-palette-empty {
display: grid;
place-items: center;
gap: var(--space-2);
min-height: 180px;
color: var(--text-secondary);
text-align: center;
}
.command-palette-empty .ti {
font-size: 28px;
}
body.command-palette-open {
overflow: hidden;
}
@media (max-width: 720px) {
.command-palette-dialog {
width: calc(100vw - 16px);
max-height: calc(100vh - 24px);
margin-top: 12px;
}
}
/* ============================================================
9. ТАБЛИЦЫ
============================================================ */
@@ -2863,7 +2993,6 @@ table.table {
cursor: pointer;
display: inline-grid;
place-items: center;
font-variant-numeric: tabular-nums;
transition: border-color 0.12s, color 0.12s, background-color 0.12s;
}
.pagination .page:hover,
@@ -3044,6 +3173,14 @@ table.table {
.modal-footer .mf-left {
margin-right: auto;
}
.confirm-modal-body {
padding-top: var(--space-4);
padding-bottom: var(--space-5);
}
.confirm-modal-body p {
margin: 0;
line-height: 1.55;
}
.dialog-icon {
width: 42px;
height: 42px;
@@ -3571,6 +3708,34 @@ table.table {
[data-theme="dark"] .alert-integration {
color: #67e8f9;
}
.module-dependency-notices {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.module-dependency-notices .alert {
align-items: center;
}
.module-dependency-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.module-dependency-notices .btn {
margin-left: auto;
flex: 0 0 auto;
}
@media (max-width: 640px) {
.module-dependency-notices .alert {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
}
.module-dependency-notices .btn {
grid-column: 1/-1;
width: 100%;
margin-left: 0;
}
}
.banner {
display: flex;
align-items: center;
@@ -4965,8 +5130,8 @@ ul.sortable {
}
.media-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 10px;
grid-auto-rows: max-content;
flex: 1 1 auto;
align-content: start;
@@ -4975,7 +5140,7 @@ ul.sortable {
}
.media-picker-item {
display: grid;
grid-template-rows: auto minmax(62px, auto);
grid-template-rows: auto minmax(54px, auto);
gap: 0;
min-width: 0;
padding: 0;
@@ -5025,7 +5190,7 @@ ul.sortable {
align-content: center;
gap: 4px;
min-width: 0;
padding: 10px 11px;
padding: 8px 9px;
background: var(--background-card);
}
.media-picker-item b,
@@ -5072,7 +5237,7 @@ ul.sortable {
}
.media-picker-item.is-folder {
grid-template-columns: none;
grid-template-rows: auto minmax(62px, auto);
grid-template-rows: auto minmax(54px, auto);
align-items: stretch;
gap: 0;
min-height: 0;
@@ -5080,7 +5245,7 @@ ul.sortable {
}
.media-picker-item.is-folder .media-picker-meta {
align-content: center;
padding: 10px 11px;
padding: 8px 9px;
background: var(--background-card);
}
.media-picker-folder-icon {
@@ -5102,6 +5267,28 @@ ul.sortable {
font-size: 12px;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.media-picker {
width: 100%;
height: 94vh;
max-height: 94vh;
}
.media-picker-tools {
flex-wrap: wrap;
}
.media-picker-tools .media-picker-search {
flex-basis: calc(100% - 50px);
}
.media-picker-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.media-picker .modal-footer {
padding: 12px 14px;
}
.media-picker-count {
flex-basis: 100%;
}
}
/* ============================================================
14. БОЛЬШИЕ ФОРМЫ / FORM BUILDER / CMS / ПРОЧЕЕ
============================================================ */
@@ -6422,7 +6609,6 @@ body.sidebar-collapsed .sidebar .nav-sub.flyout-open .nav-item .nav-count {
font-size: 20px;
font-weight: 800;
font-family: var(--font-display, inherit);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.stat-label {
+42 -10
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];
@@ -591,7 +598,7 @@
overlay.className = 'overlay';
var modal = document.createElement('div');
modal.className = 'modal';
modal.className = 'modal confirm-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
@@ -600,10 +607,10 @@
'<span class="dialog-icon ' + ic[0] + '"><i class="ti ' + ic[1] + '"></i></span>' +
'<div style="flex:1">' +
'<h3>' + esc(Adminx.tr(cfg.title || Adminx.t('confirm_title', 'Подтвердите действие'))) + '</h3>' +
(cfg.message ? '<p class="text-secondary" style="margin-top:4px">' + esc(Adminx.tr(cfg.message)) + '</p>' : '') +
'</div>' +
'<button class="modal-close" type="button" data-cancel aria-label="' + esc(Adminx.t('btn_close', 'Закрыть')) + '"><i class="ti ti-x"></i></button>' +
'</div>' +
(cfg.message ? '<div class="modal-body confirm-modal-body"><p class="text-secondary">' + esc(Adminx.tr(cfg.message)) + '</p></div>' : '') +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" type="button" data-cancel>' + esc(Adminx.tr(cfg.cancelLabel || Adminx.t('btn_cancel', 'Отмена'))) + '</button>' +
'<button class="btn ' + (cfg.confirmClass || 'btn-primary') + '" type="button" data-ok style="margin-left:auto">' + esc(Adminx.tr(cfg.confirmLabel || Adminx.t('confirm_action', 'Подтвердить'))) + '</button>' +
@@ -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;
}
};
+1
View File
@@ -0,0 +1 @@
(()=>{(function(h,c){"use strict";var a=h.Adminx||(h.Adminx={});function d(t){var r=t.getAttribute("data-bulk-target")||"";return{items:Array.prototype.slice.call(c.querySelectorAll('[data-bulk-item="'+r+'"]')),all:Array.prototype.slice.call(c.querySelectorAll('[data-bulk-all="'+r+'"]'))}}function u(t){var r=d(t),e=r.items.filter(function(n){return n.checked&&!n.disabled}),l=r.items.filter(function(n){return!n.disabled}),i=t.querySelector("[data-bulk-count]");t.hidden=e.length===0,t.classList.toggle("visible",e.length>0),i&&(i.textContent=e.length),r.all.forEach(function(n){n.checked=l.length>0&&e.length===l.length,n.indeterminate=e.length>0&&e.length<l.length})}function b(t){var r=d(t);r.items.concat(r.all).forEach(function(e){e.checked=!1,e.indeterminate=!1}),u(t)}function k(t){var r=d(t),e=r.items.filter(function(s){return s.checked&&!s.disabled}).map(function(s){return s.value}),l=t.querySelector("[data-bulk-action]"),i=l&&l.options[l.selectedIndex]?l.options[l.selectedIndex]:null;if(!l||!l.value||!e.length){a.Toast.show(a.tr("\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0437\u0430\u043F\u0438\u0441\u0438 \u0438 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435"),"warning");return}var n=t.getAttribute("data-bulk-endpoint")||"",p=i.getAttribute("data-label")||i.textContent||l.value,m=i.getAttribute("data-kind")||"warning";a.Confirm.open({kind:m,title:a.tr("\u0412\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C \u043C\u0430\u0441\u0441\u043E\u0432\u043E\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435?"),message:a.tr("\u0412\u044B\u0431\u0440\u0430\u043D\u043E \u0437\u0430\u043F\u0438\u0441\u0435\u0439")+": "+e.length+". "+a.tr("\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435")+": "+p+".",confirmLabel:a.tr("\u041F\u0440\u0438\u043C\u0435\u043D\u0438\u0442\u044C"),onConfirm:function(){var s=new FormData;s.append("action",l.value),e.forEach(function(f){s.append("ids[]",f)}),a.Loader.show(),a.Ajax.post(n,s).then(function(f){a.Loader.hide();var g=f.data||{};if(!f.ok||g.success===!1){a.Toast.show(g.message||a.tr("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435"),"error");return}var o=g.data||{},v=(g.message||a.tr("\u0413\u043E\u0442\u043E\u0432\u043E"))+" \xB7 "+a.tr("\u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0430\u043D\u043E")+" "+(o.done||0);o.skipped&&(v+=", "+a.tr("\u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E")+" "+o.skipped),a.Toast.show(v,o.errors&&o.errors.length?"warning":"success"),c.dispatchEvent(new CustomEvent("adminx:bulk-complete",{detail:{root:t,result:o}})),t.getAttribute("data-bulk-reload")!=="false"?h.location.reload():b(t)}).catch(function(){a.Loader.hide(),a.Toast.show(a.tr("\u0421\u0435\u0440\u0432\u0435\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443."),"error")})}})}c.addEventListener("change",function(t){var r=t.target.closest("[data-bulk-item]"),e=t.target.closest("[data-bulk-all]");if(!(!r&&!e)){var l=(r||e).getAttribute(r?"data-bulk-item":"data-bulk-all"),i=c.querySelector('[data-bulk-actions][data-bulk-target="'+l+'"]');i&&(e&&d(i).items.forEach(function(n){n.disabled||(n.checked=e.checked)}),u(i))}}),c.addEventListener("click",function(t){var r=t.target.closest("[data-bulk-apply]"),e=t.target.closest("[data-bulk-clear]"),l=r||e?(r||e).closest("[data-bulk-actions]"):null;l&&(r?k(l):b(l))}),c.querySelectorAll("[data-bulk-actions]").forEach(u),a.BulkActions={update:u,clear:b}})(window,document);})();
+1
View File
@@ -0,0 +1 @@
(()=>{(function(C,l){"use strict";var i=l.querySelector("[data-command-palette]");if(!i)return;var c=i.querySelector("[data-command-palette-input]"),u=i.querySelector("[data-command-palette-results]"),L=i.getAttribute("data-base")||"",v=[],m=[],a=0,s=null,d=0;try{v=JSON.parse(i.querySelector("[data-command-palette-items]").textContent||"[]")}catch(t){v=[]}function S(t){return String(t||"").toLocaleLowerCase()}function E(t){return t=S(t),v.filter(function(e){return!t||S(e.title+" "+e.subtitle).indexOf(t)!==-1}).slice(0,t?10:18).map(function(e){return{group:"\u0420\u0430\u0437\u0434\u0435\u043B\u044B",title:e.title,subtitle:e.subtitle,url:e.url,icon:e.icon}})}function h(){return E(c.value).concat(m)}function T(t){u.innerHTML="";var e=l.createElement("div");e.className="command-palette-empty",e.innerHTML='<i class="ti ti-search-off"></i><span></span>',e.querySelector("span").textContent=t,u.appendChild(e)}function p(){var t=h();if(!t.length){T(c.value.trim().length<2?"\u041D\u0430\u0447\u043D\u0438\u0442\u0435 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435":"\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E");return}a=Math.max(0,Math.min(a,t.length-1)),u.innerHTML="";var e="";t.forEach(function(r,b){if(r.group!==e){var g=l.createElement("div");g.className="command-palette-group",g.textContent=r.group,u.appendChild(g),e=r.group}var n=l.createElement("button");n.className="command-palette-item"+(b===a?" is-active":""),n.type="button",n.setAttribute("role","option"),n.setAttribute("aria-selected",b===a?"true":"false"),n.dataset.index=b,n.innerHTML='<span class="icon-tile" style="--tile-bg:var(--blue-50);--tile-fg:var(--blue-700)"><i class="ti"></i></span><span class="command-palette-copy"><b></b><small></small></span><i class="ti ti-arrow-right"></i>';var f=String(r.icon||"ti-arrow-right").trim();/(^|\s)ti(\s|$)/.test(f)||(f="ti "+f),n.querySelector(".icon-tile .ti").className=f,n.querySelector("b").textContent=r.title||"",n.querySelector("small").textContent=r.subtitle||"",u.appendChild(n)});var o=u.querySelector(".command-palette-item.is-active");o&&o.scrollIntoView({block:"nearest"})}function q(){var t=c.value.trim();m=[],a=0,p(),clearTimeout(s),s=null;var e=++d;t.length<2||(s=setTimeout(function(){fetch(L+"/search?q="+encodeURIComponent(t),{credentials:"same-origin",headers:{Accept:"application/json"}}).then(function(o){return o.json()}).then(function(o){e===d&&(m=o.data&&o.data.items?o.data.items.map(function(r){return r.url=L+r.url,r}):[],p())}).catch(function(){})},180))}function k(){clearTimeout(s),s=null,d++,i.hidden=!1,l.body.classList.add("command-palette-open"),c.value="",m=[],a=0,p(),C.setTimeout(function(){c.focus()},0)}function y(){clearTimeout(s),s=null,d++,i.hidden=!0,l.body.classList.remove("command-palette-open")}function w(t){var e=h()[t];e&&e.url&&C.location.assign(e.url)}l.addEventListener("click",function(t){if(t.target.closest("[data-command-palette-open]")){k();return}if(t.target.closest("[data-command-palette-close]")){y();return}var e=t.target.closest(".command-palette-item");e&&w(parseInt(e.dataset.index,10)||0)}),l.addEventListener("keydown",function(t){if((t.ctrlKey||t.metaKey)&&t.key.toLowerCase()==="k"){t.preventDefault(),i.hidden?k():y();return}i.hidden||(t.key==="Escape"&&(t.preventDefault(),y()),t.key==="ArrowDown"&&(t.preventDefault(),a=Math.min(h().length-1,a+1),p()),t.key==="ArrowUp"&&(t.preventDefault(),a=Math.max(0,a-1),p()),t.key==="Enter"&&(t.preventDefault(),w(a)))}),c.addEventListener("input",q)})(window,document);})();
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -91,7 +91,10 @@
renderCrumbs(data.breadcrumbs || []);
grid.innerHTML = '';
folders.forEach(function (folder) {
grid.insertAdjacentHTML('beforeend', '<button class="media-picker-item is-folder" type="button" data-mp-dir="' + esc(folder.path || '') + '"><span class="media-picker-thumb"><span class="media-picker-folder-icon"><i class="ti ti-folder"></i></span></span><span class="media-picker-meta"><b>' + esc(folder.name || '') + '</b><small><i class="ti ti-files"></i>' + esc(folder.count || 0) + ' объектов</small></span></button>');
var folderMeta = state.q
? '<i class="ti ti-folder-open"></i>' + esc(String(folder.path || '').replace(/^\/uploads\/?/, '') || 'uploads')
: '<i class="ti ti-files"></i>' + esc(folder.count || 0) + ' объектов';
grid.insertAdjacentHTML('beforeend', '<button class="media-picker-item is-folder" type="button" data-mp-dir="' + esc(folder.path || '') + '"><span class="media-picker-thumb"><span class="media-picker-folder-icon"><i class="ti ti-folder"></i></span></span><span class="media-picker-meta"><b>' + esc(folder.name || '') + '</b><small>' + folderMeta + '</small></span></button>');
});
files.forEach(function (file) {
var thumb = file.thumb_url || file.preview_url || file.url || '';
+117
View File
@@ -0,0 +1,117 @@
(function (window, document) {
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
function controls(root) {
var target = root.getAttribute('data-bulk-target') || '';
return {
items: Array.prototype.slice.call(document.querySelectorAll('[data-bulk-item="' + target + '"]')),
all: Array.prototype.slice.call(document.querySelectorAll('[data-bulk-all="' + target + '"]'))
};
}
function update(root) {
var nodes = controls(root);
var selected = nodes.items.filter(function (item) { return item.checked && !item.disabled; });
var available = nodes.items.filter(function (item) { return !item.disabled; });
var count = root.querySelector('[data-bulk-count]');
root.hidden = selected.length === 0;
root.classList.toggle('visible', selected.length > 0);
if (count) { count.textContent = selected.length; }
nodes.all.forEach(function (all) {
all.checked = available.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
});
}
function clear(root) {
var nodes = controls(root);
nodes.items.concat(nodes.all).forEach(function (item) {
item.checked = false;
item.indeterminate = false;
});
update(root);
}
function apply(root) {
var nodes = controls(root);
var ids = nodes.items.filter(function (item) {
return item.checked && !item.disabled;
}).map(function (item) {
return item.value;
});
var select = root.querySelector('[data-bulk-action]');
var option = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex] : null;
if (!select || !select.value || !ids.length) {
Adminx.Toast.show(Adminx.tr('Выберите записи и действие'), 'warning');
return;
}
var endpoint = root.getAttribute('data-bulk-endpoint') || '';
var label = option.getAttribute('data-label') || option.textContent || select.value;
var kind = option.getAttribute('data-kind') || 'warning';
Adminx.Confirm.open({
kind: kind,
title: Adminx.tr('Выполнить массовое действие?'),
message: Adminx.tr('Выбрано записей') + ': ' + ids.length + '. ' + Adminx.tr('Действие') + ': ' + label + '.',
confirmLabel: Adminx.tr('Применить'),
onConfirm: function () {
var data = new FormData();
data.append('action', select.value);
ids.forEach(function (id) { data.append('ids[]', id); });
Adminx.Loader.show();
Adminx.Ajax.post(endpoint, data).then(function (payload) {
Adminx.Loader.hide();
var body = payload.data || {};
if (!payload.ok || body.success === false) {
Adminx.Toast.show(body.message || Adminx.tr('Не удалось выполнить действие'), 'error');
return;
}
var result = body.data || {};
var message = (body.message || Adminx.tr('Готово')) + ' · ' + Adminx.tr('обработано') + ' ' + (result.done || 0);
if (result.skipped) { message += ', ' + Adminx.tr('пропущено') + ' ' + result.skipped; }
Adminx.Toast.show(message, result.errors && result.errors.length ? 'warning' : 'success');
document.dispatchEvent(new CustomEvent('adminx:bulk-complete', {
detail: { root: root, result: result }
}));
if (root.getAttribute('data-bulk-reload') !== 'false') {
window.location.reload();
} else {
clear(root);
}
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show(Adminx.tr('Сервер недоступен. Повторите попытку.'), 'error');
});
}
});
}
document.addEventListener('change', function (event) {
var item = event.target.closest('[data-bulk-item]');
var all = event.target.closest('[data-bulk-all]');
if (!item && !all) { return; }
var target = (item || all).getAttribute(item ? 'data-bulk-item' : 'data-bulk-all');
var root = document.querySelector('[data-bulk-actions][data-bulk-target="' + target + '"]');
if (!root) { return; }
if (all) {
controls(root).items.forEach(function (checkbox) {
if (!checkbox.disabled) { checkbox.checked = all.checked; }
});
}
update(root);
});
document.addEventListener('click', function (event) {
var applyButton = event.target.closest('[data-bulk-apply]');
var clearButton = event.target.closest('[data-bulk-clear]');
var root = (applyButton || clearButton) ? (applyButton || clearButton).closest('[data-bulk-actions]') : null;
if (!root) { return; }
if (applyButton) { apply(root); } else { clear(root); }
});
document.querySelectorAll('[data-bulk-actions]').forEach(update);
Adminx.BulkActions = { update: update, clear: clear };
})(window, document);
+162
View File
@@ -0,0 +1,162 @@
(function (window, document) {
'use strict';
var root = document.querySelector('[data-command-palette]');
if (!root) { return; }
var input = root.querySelector('[data-command-palette-input]');
var results = root.querySelector('[data-command-palette-results]');
var base = root.getAttribute('data-base') || '';
var commands = [];
var remote = [];
var active = 0;
var timer = null;
var request = 0;
try {
commands = JSON.parse(root.querySelector('[data-command-palette-items]').textContent || '[]');
} catch (error) {
commands = [];
}
function normalized(value) {
return String(value || '').toLocaleLowerCase();
}
function localItems(query) {
query = normalized(query);
return commands.filter(function (item) {
return !query || normalized(item.title + ' ' + item.subtitle).indexOf(query) !== -1;
}).slice(0, query ? 10 : 18).map(function (item) {
return {
group: 'Разделы',
title: item.title,
subtitle: item.subtitle,
url: item.url,
icon: item.icon
};
});
}
function allItems() {
return localItems(input.value).concat(remote);
}
function empty(message) {
results.innerHTML = '';
var node = document.createElement('div');
node.className = 'command-palette-empty';
node.innerHTML = '<i class="ti ti-search-off"></i><span></span>';
node.querySelector('span').textContent = message;
results.appendChild(node);
}
function render() {
var items = allItems();
if (!items.length) {
empty(input.value.trim().length < 2 ? 'Начните вводить название' : 'Ничего не найдено');
return;
}
active = Math.max(0, Math.min(active, items.length - 1));
results.innerHTML = '';
var lastGroup = '';
items.forEach(function (item, index) {
if (item.group !== lastGroup) {
var heading = document.createElement('div');
heading.className = 'command-palette-group';
heading.textContent = item.group;
results.appendChild(heading);
lastGroup = item.group;
}
var button = document.createElement('button');
button.className = 'command-palette-item' + (index === active ? ' is-active' : '');
button.type = 'button';
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', index === active ? 'true' : 'false');
button.dataset.index = index;
button.innerHTML = '<span class="icon-tile" style="--tile-bg:var(--blue-50);--tile-fg:var(--blue-700)"><i class="ti"></i></span><span class="command-palette-copy"><b></b><small></small></span><i class="ti ti-arrow-right"></i>';
var iconClass = String(item.icon || 'ti-arrow-right').trim();
if (!/(^|\s)ti(\s|$)/.test(iconClass)) { iconClass = 'ti ' + iconClass; }
button.querySelector('.icon-tile .ti').className = iconClass;
button.querySelector('b').textContent = item.title || '';
button.querySelector('small').textContent = item.subtitle || '';
results.appendChild(button);
});
var selected = results.querySelector('.command-palette-item.is-active');
if (selected) { selected.scrollIntoView({ block: 'nearest' }); }
}
function search() {
var query = input.value.trim();
remote = [];
active = 0;
render();
clearTimeout(timer);
timer = null;
var token = ++request;
if (query.length < 2) { return; }
timer = setTimeout(function () {
fetch(base + '/search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (token !== request) { return; }
remote = json.data && json.data.items ? json.data.items.map(function (item) {
item.url = base + item.url;
return item;
}) : [];
render();
})
.catch(function () {});
}, 180);
}
function open() {
clearTimeout(timer);
timer = null;
request++;
root.hidden = false;
document.body.classList.add('command-palette-open');
input.value = '';
remote = [];
active = 0;
render();
window.setTimeout(function () { input.focus(); }, 0);
}
function close() {
clearTimeout(timer);
timer = null;
request++;
root.hidden = true;
document.body.classList.remove('command-palette-open');
}
function use(index) {
var item = allItems()[index];
if (item && item.url) { window.location.assign(item.url); }
}
document.addEventListener('click', function (event) {
if (event.target.closest('[data-command-palette-open]')) { open(); return; }
if (event.target.closest('[data-command-palette-close]')) { close(); return; }
var item = event.target.closest('.command-palette-item');
if (item) { use(parseInt(item.dataset.index, 10) || 0); }
});
document.addEventListener('keydown', function (event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
root.hidden ? open() : close();
return;
}
if (root.hidden) { return; }
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'ArrowDown') { event.preventDefault(); active = Math.min(allItems().length - 1, active + 1); render(); }
if (event.key === 'ArrowUp') { event.preventDefault(); active = Math.max(0, active - 1); render(); }
if (event.key === 'Enter') { event.preventDefault(); use(active); }
});
input.addEventListener('input', search);
})(window, document);
+1 -1
View File
@@ -478,7 +478,7 @@ var EnhancedImage = Image.extend({
params.set('limit', 40);
status.hidden = false;
status.textContent = 'Загрузка...';
fetch((window.ADMINX_BASE || '/adminx') + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
fetch((window.ADMINX_BASE || (Adminx.base ? Adminx.base() : '')) + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (response) { return response.json(); })
.then(function (payload) {
if (current !== request) { return; }
+1 -1
View File
@@ -25,7 +25,7 @@
'code' => 'admin_panel',
'group_code' => 'core',
'name' => 'Доступ в панель управления',
'description' => 'Разрешает вход в административную панель /adminx.',
'description' => 'Разрешает вход в панель управления.',
'sort_order' => 1,
),
array(
+30
View File
@@ -119,6 +119,8 @@
AdminAssets::addScript(ADMINX_BASE . '/assets/js/adminx.js', 1);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/media-picker.js', 2);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/saved-views.js', 3);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/command-palette.js', 4);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/bulk-actions.js', 5);
//-- Cache-busting ассетов: ?v=<filemtime>. На время разработки браузер не держит
//-- устаревший CSS/JS — при правке файла (или пересборке CSS) меняется mtime и URL.
@@ -147,9 +149,13 @@
}
$requestPath = '/' . ltrim($requestPath, '/');
$requestQuery = parse_url(isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '', PHP_URL_QUERY);
$requestUri = $requestPath . (is_string($requestQuery) && $requestQuery !== '' ? '?' . $requestQuery : '');
Twig::addGlobal('current_path', $requestPath);
Twig::addGlobal('current_uri', $requestUri);
Twig::addGlobal('section_help', \App\Adminx\Support\SectionHelp::forPath($requestPath));
Twig::addGlobal('module_dependency_notices', \App\Adminx\Support\ModuleExtensions::dependencyNotices($requestPath));
//-- Доступ: публичные роуты (логин) открыты; всё прочее требует admin_panel.
//-- Современный Auth API (SystemTables users: email/password_hash/role);
@@ -166,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 = [];
@@ -189,6 +207,17 @@
}
}
$commandPaletteItems = array();
foreach ($navigationItems as $navItem) {
if (!isset($navItem['url']) || (string) $navItem['url'] === '#') { continue; }
$commandPaletteItems[] = array(
'title' => isset($navItem['label']) ? (string) $navItem['label'] : '',
'subtitle' => isset($navItem['group']) ? (string) $navItem['group'] : '',
'url' => ADMINX_BASE . (string) $navItem['url'],
'icon' => isset($navItem['icon']) ? (string) $navItem['icon'] : 'ti-arrow-right',
);
}
$canClearCache = Permission::check('manage_settings');
$cacheSizes = array();
if ($canClearCache) {
@@ -204,6 +233,7 @@
Twig::addGlobals([
'nav_items' => $navItems,
'command_palette_items' => $commandPaletteItems,
'user_name' => $authUser['name'] ?? '',
'user_email' => $authUser['email'] ?? '',
'user_role' => $authUser['role'] ?? '',
+2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="action_done">Done</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Delete view</phrase>
<phrase data="auto_86433d57abc272e9">Saved Views</phrase>
<phrase data="btn_apply">Apply</phrase>
@@ -37,6 +38,7 @@
<phrase data="ui_remove">Remove</phrase>
<phrase data="ui_search">Search</phrase>
<phrase data="ui_select">Select</phrase>
<phrase data="ui_selected">Selected</phrase>
<phrase data="ui_size">Size</phrase>
<phrase data="ui_source">Source</phrase>
<phrase data="ui_title">Title</phrase>
+1 -1
View File
@@ -368,7 +368,7 @@
<phrase data="runtime_87e4da4d68321ed6">Unlocking returns access immediately.</phrase>
<phrase data="runtime_58be617699e0fe18">Sections define the directory structure and the set of available fields.</phrase>
<phrase data="runtime_56274d1ca16887fe">Allows you to see a public site when it is temporarily closed to visitors and search engines.</phrase>
<phrase data="runtime_c3995f01449595d9">Allows login to the administrative panel /adminx.</phrase>
<phrase data="runtime_c3995f01449595d9">Allows login to the control panel.</phrase>
<phrase data="runtime_8ad60574b1f35312">Allow only those types of objects on which discussions are actually needed.</phrase>
<phrase data="runtime_3b7123a2c037efc8">Distributes visitors between options and collects impressions and conversions without mixing options in a shared cache.</phrase>
<phrase data="runtime_618a47736f884790">The calculation is included separately and uses only fully filled packaging.</phrase>
+2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="action_done">Готово</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Удалить представление</phrase>
<phrase data="auto_86433d57abc272e9">Сохранённые представления</phrase>
<phrase data="btn_apply">Применить</phrase>
@@ -37,6 +38,7 @@
<phrase data="ui_remove">Убрать</phrase>
<phrase data="ui_search">Поиск</phrase>
<phrase data="ui_select">Выбрать</phrase>
<phrase data="ui_selected">Выбрано</phrase>
<phrase data="ui_size">Размер</phrase>
<phrase data="ui_source">Источник</phrase>
<phrase data="ui_title">Заголовок</phrase>
+1 -1
View File
@@ -368,7 +368,7 @@
<phrase data="runtime_87e4da4d68321ed6">Разблокировка возвращает доступ сразу.</phrase>
<phrase data="runtime_58be617699e0fe18">Разделы задают структуру каталога и набор доступных полей.</phrase>
<phrase data="runtime_56274d1ca16887fe">Разрешает видеть публичный сайт, когда он временно закрыт для посетителей и поисковых систем.</phrase>
<phrase data="runtime_c3995f01449595d9">Разрешает вход в административную панель /adminx.</phrase>
<phrase data="runtime_c3995f01449595d9">Разрешает вход в панель управления.</phrase>
<phrase data="runtime_8ad60574b1f35312">Разрешите только те типы объектов, на которых действительно нужны обсуждения.</phrase>
<phrase data="runtime_3b7123a2c037efc8">Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше.</phrase>
<phrase data="runtime_618a47736f884790">Расчёт включается отдельно и использует только полностью заполненную упаковку.</phrase>
+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')),
),
+1 -1
View File
@@ -38,6 +38,6 @@
</form>
{% endfor %}
</div>
<div class="auth-foot text-muted text-xs">AVE.cms · /adminx</div>
<div class="auth-foot text-muted text-xs">AVE.cms · Панель управления</div>
</div>
{% endblock %}
+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 %}
+10
View File
@@ -285,6 +285,16 @@
return false;
}
$dependencies = \App\Content\ContentTagDependencies::block(
(int) $id,
isset($raw['sysblock_alias']) ? (string) $raw['sysblock_alias'] : ''
);
if ($dependencies) {
throw new \RuntimeException(
'Блок используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти вызовы.'
);
}
Revisions::capture((int) $id, 'delete', (int) $authorId, 'Удаление блока', $raw);
$row = self::row($raw);
if (!$row) {
+34 -104
View File
@@ -17,12 +17,13 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use DB;
use App\Common\SystemTables;
use App\Content\ContentTables;
use App\Helpers\Json;
use App\Content\Revisions\JsonRevisionStore;
class Revisions
{
protected static $store;
public static function table()
{
return ContentTables::table('sysblock_revisions');
@@ -43,14 +44,8 @@
public static function listForBlock($blockId, $limit = 50)
{
$rows = DB::query(
'SELECT * FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT %i',
(int) $blockId,
max(1, min(200, (int) $limit))
)->getAll();
$out = array();
foreach ($rows as $row) {
foreach (self::store()->listing($blockId, $limit) as $row) {
$out[] = self::format($row, false);
}
@@ -59,7 +54,7 @@
public static function one($id)
{
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc();
$row = self::store()->one($id);
return $row ? self::format($row, true) : null;
}
@@ -75,38 +70,19 @@
}
$snapshot = Model::snapshot($snapshot);
$json = Json::encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
$json = '{}';
}
$snapshotHash = sha1($json);
$textHash = sha1((string) $snapshot['sysblock_text']);
if (in_array($action, array('update', 'import', 'restore'), true)) {
$last = DB::query(
'SELECT snapshot_hash FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT 1',
$blockId
)->getValue();
if ($last && (string) $last === $snapshotHash) {
return 0;
}
}
DB::Insert(self::table(), array(
'block_id' => $blockId,
'action' => (string) $action,
'snapshot_hash' => $snapshotHash,
return self::store()->capture(
$blockId,
$action,
$snapshot,
$authorId,
$comment,
array(
'text_hash' => $textHash,
'snapshot_json' => $json,
'comment' => trim((string) $comment),
'author_id' => (int) $authorId,
'author_name' => self::authorName((int) $authorId),
'source_revision_id' => (int) $sourceRevisionId,
'created_at' => time(),
));
return (int) DB::insertId();
),
in_array($action, array('update', 'import', 'restore'), true)
);
}
public static function captureCurrent($action, $authorId = 0, $comment = '')
@@ -144,98 +120,52 @@
public static function delete($revisionId)
{
$revision = self::one($revisionId);
if (!$revision) {
return false;
}
DB::Delete(self::table(), 'id = %i', (int) $revisionId);
return (int) $revision['block_id'];
return self::store()->delete($revisionId);
}
public static function deleteForBlock($blockId)
{
$blockId = (int) $blockId;
if ($blockId <= 0) {
return 0;
}
$count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE block_id = %i', $blockId)->getValue();
DB::Delete(self::table(), 'block_id = %i', $blockId);
return $count;
return self::store()->deleteFor($blockId);
}
protected static function format(array $row, $withSnapshot)
{
$labels = self::labels();
$action = isset($row['action']) ? (string) $row['action'] : 'update';
$meta = isset($labels[$action]) ? $labels[$action] : array('label' => $action, 'badge' => 'badge-gray');
$snapshot = null;
if ($withSnapshot) {
$snapshot = Json::toArray((string) $row['snapshot_json']);
}
$created = isset($row['created_at']) ? (int) $row['created_at'] : 0;
$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'],
'block_id' => (int) $row['block_id'],
'action' => $action,
'action_label' => $meta['label'],
'badge' => $meta['badge'],
'action' => $row['action'],
'action_label' => $row['action_label'],
'badge' => $row['badge'],
'comment' => (string) $row['comment'],
'author_id' => (int) $row['author_id'],
'author_name' => (string) $row['author_name'],
'created_at' => $created,
'created_label' => $created > 0 ? date('d.m.Y H:i:s', $created) : '-',
'created_at' => $row['created_at'],
'created_label' => $row['created_label'],
'source_revision_id' => (int) $row['source_revision_id'],
'snapshot_hash' => (string) $row['snapshot_hash'],
'text_hash' => (string) $row['text_hash'],
'text_size' => strlen($text),
'text_size_label' => $withSnapshot ? self::formatBytes(strlen($text)) : '',
'text_size_label' => $withSnapshot ? JsonRevisionStore::formatBytes(strlen($text)) : '',
'snapshot' => $snapshot,
'code' => $text,
'comparison' => $comparison,
);
}
protected static function authorName($id)
protected static function store()
{
if ((int) $id <= 0) {
return '';
if (!self::$store) {
self::$store = new JsonRevisionStore(self::table(), 'block_id', self::labels());
}
$row = DB::query('SELECT name, login, email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc();
if (!$row) {
return '#' . (int) $id;
}
if (!empty($row['name'])) {
return (string) $row['name'];
}
if (!empty($row['login'])) {
return '@' . (string) $row['login'];
}
if (!empty($row['email'])) {
return (string) $row['email'];
}
return '#' . (int) $id;
}
protected static function formatBytes($bytes)
{
$bytes = (int) $bytes;
if ($bytes < 1024) {
return $bytes . ' Б';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
}
return round($bytes / 1048576, 1) . ' MB';
return self::$store;
}
}
-2
View File
@@ -19,7 +19,6 @@
font-size: 20px;
line-height: 1.1;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.blocks-stat span {
font-size: 13px;
@@ -212,7 +211,6 @@
gap: 2px;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.blocks-actions {
gap: 4px;
+6 -2
View File
@@ -151,6 +151,8 @@
applyFilters: function (form, push) {
if (!form) { return; }
clearTimeout(this.filterTimer);
this.filterTimer = null;
var url = this.filterUrl(form);
this.applyFilterUrl(url, push);
},
@@ -718,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)); }
@@ -829,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 =
@@ -843,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',
File diff suppressed because it is too large Load Diff
+196 -21
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;
@@ -217,7 +366,6 @@
gap: 12px;
flex: 0 0 auto;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.catalog-field-groups {
display: grid;
@@ -299,7 +447,6 @@
flex: 0 0 auto;
color: var(--text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.catalog-field-group-meta .badge {
min-width: 88px;
@@ -459,7 +606,6 @@
margin-bottom: 10px;
color: var(--text-secondary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.catalog-recompile-errors {
margin-top: 14px;
@@ -744,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;
@@ -776,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;
}
}
File diff suppressed because one or more lines are too long
+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',
+11 -10
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>
@@ -38,7 +39,7 @@
<div class="catalog-card-body"><div class="form-grid">
<label class="field col-3"><span class="field-label">Назначение каталога</span><select class="select" name="purpose"><option value="content"{{ settings.purpose == 'content' ? ' selected' : '' }}>Обычный каталог</option>{% if products_available %}<option value="commerce"{{ settings.purpose == 'commerce' ? ' selected' : '' }}>Товарный каталог</option>{% endif %}</select><span class="field-hint">{% if products_available %}Товарный режим включает коммерческий индекс, цены, остатки, варианты и фиды.{% else %}Товарный режим доступен после установки модуля «Товары».{% endif %}</span></label>
<label class="field col-3"><span class="field-label">Запрос фильтра</span><select class="select" name="request_id"><option value="0">Не выбран</option>{% for item in request_options %}<option value="{{ item.id }}"{{ settings.request_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Запрос, который формирует выдачу и условия фильтрации.</span></label>
<label class="field col-3"><span class="field-label">Навигация</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Меню, используемое для экспорта структуры каталога.</span></label>
<label class="field col-3"><span class="field-label">Навигация в шапке</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Дополнительные ссылки и подборки перед разделами каталога.</span></label>
<label class="field col-3"><span class="field-label">Рубрика разделов</span><select class="select" name="rub_cat_id"><option value="0">Не выбрана</option>{% for item in rubric_options %}<option value="{{ item.id }}"{{ settings.rub_cat_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Рубрика документов, связанных с разделами каталога.</span></label>
</div>
{% if products_available %}<section class="catalog-commerce-fields" data-catalog-commerce-fields><div class="catalog-behavior-head"><span class="icon-tile catalog-behavior-icon is-green"><i class="ti ti-shopping-bag"></i></span><h3>Поля товарного представления</h3></div><div class="form-grid">{% set commerce_fields={'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':'Изображения'} %}{% for key,label in commerce_fields %}<label class="field col-4"><span class="field-label">{{ label }}</span><select class="select" name="{{ key }}"><option value="0">Не назначено</option>{% for group in field_groups %}{% for field in group.items %}<option value="{{ field.id }}"{{ attribute(settings,key) == field.id ? ' selected' : '' }}>{{ field.title ?: field.alias }} · #{{ field.id }}</option>{% endfor %}{% endfor %}</select></label>{% endfor %}</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'),
);
}
+329 -8
View File
@@ -33,8 +33,309 @@
.customers-table td:last-child {
text-align: right;
}
.customers-table td:first-child {
font-variant-numeric: tabular-nums;
.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);
@@ -83,7 +384,6 @@
min-width: 0;
overflow: hidden;
font-size: 13px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -101,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;
@@ -801,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 {
@@ -810,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 {
@@ -831,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,
@@ -852,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>
@@ -10,7 +10,6 @@
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
/* ── KPI ───────────────────────────────────────────── */
.dashboard-kpis {
@@ -62,7 +61,6 @@
color: var(--text-primary);
font-size: 24px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.dashboard-delta {
@@ -75,7 +73,6 @@
border-radius: 999px;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.dashboard-delta .ti {
font-size: 14px;
@@ -154,7 +151,6 @@
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dashboard-chart-legend small {
color: var(--text-secondary);
@@ -216,7 +212,6 @@
border-top: 1px solid var(--border-default);
color: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.dashboard-chart-axis .is-weekend {
color: var(--text-secondary);
@@ -253,7 +248,6 @@
.dashboard-status-count {
font-weight: 700;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.dashboard-status-bar {
grid-column: 1 / -1;
@@ -350,7 +344,6 @@
.dashboard-money {
font-weight: 700;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.dashboard-status-tag {
display: flex;
@@ -412,7 +405,6 @@
margin-top: 4px;
color: var(--text-secondary);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.dashboard-document-list > a > .ti {
color: var(--text-muted);
+80 -6
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()
{
@@ -58,7 +115,7 @@
'size' => filesize($path),
'size_h' => Model::human(filesize($path)),
'mtime' => filemtime($path),
'own' => strpos($f, 'adminx_') === 0,
'own' => self::isManagedName($f),
);
}
@@ -111,7 +168,7 @@
}
$dir = self::ensureDir();
$name = self::uniqueName('adminx_uploaded_' . date('Ymd_His'), $extension);
$name = self::uniqueName('avecms_uploaded_' . date('Ymd_His'), $extension);
$temporary = $dir . DS . '.upload-' . bin2hex(random_bytes(6)) . $extension;
if (!move_uploaded_file($file['tmp_name'], $temporary)) {
throw new \RuntimeException('Не удалось сохранить загруженный файл');
@@ -151,11 +208,11 @@
$prefixes = Model::prefixes();
$purpose = preg_replace('/[^a-z0-9_-]+/', '', strtolower((string) $purpose));
$name = self::uniqueName('adminx_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz');
$name = self::uniqueName('avecms_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz');
$path = $dir . DS . $name;
$temporary = $path . '.part';
$lockPath = $dir . DS . '.adminx-backup.lock';
$lockPath = $dir . DS . '.avecms-backup.lock';
$lock = fopen($lockPath, 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
if ($lock) { fclose($lock); }
@@ -169,7 +226,7 @@
throw new \RuntimeException('Не удалось создать файл дампа');
}
gzwrite($fp, "-- adminx full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() .
gzwrite($fp, "-- AVE.cms full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() .
"\n-- date: " . date('Y-m-d H:i:s') . "\n\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
$mysqli = DB::mysqli();
@@ -202,7 +259,24 @@
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-дампы поддерживаются одинаково. */
public static function isManagedName($name)
{
$name = basename((string) $name);
return strpos($name, 'avecms_') === 0 || strpos($name, 'adminx_') === 0;
}
protected static function dumpTable($fp, $table, $mysqli, $progress = null, $current = 0, $total = 0)
+1 -1
View File
@@ -32,7 +32,7 @@
public static function inspect($name, $includeToken = true)
{
$path = Backup::path($name);
if ($path === null || strpos(basename($path), 'adminx_') !== 0) {
if ($path === null || !Backup::isManagedName($path)) {
throw new \RuntimeException('Для восстановления выберите полный дамп, созданный AVE.cms');
}
+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())
{
+28 -3
View File
@@ -28,7 +28,6 @@
}
.db-tables-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
/* Размеры/отступы заголовка/сводки — общие (.ax-panel-header/.ax-summary). Здесь только акцент. */
.database-panel-header {
@@ -98,7 +97,6 @@
.database-restore-head strong {
color: var(--blue-600);
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.database-restore-track {
height: 6px;
@@ -132,7 +130,6 @@
.database-restore-current small {
margin-top: 3px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.database-restore-pulse {
width: 10px;
@@ -317,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')),
+17 -1
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>
@@ -151,7 +167,7 @@
<span class="cluster" style="gap:8px">
<i class="ti ti-file-zip text-muted"></i>
<span class="mono text-sm">{{ b.name }}</span>
{% if b.own %}<span class="badge badge-blue">adminx</span>{% endif %}
{% if b.own %}<span class="badge badge-blue">AVE.cms</span>{% endif %}
</span>
</td>
<td class="text-secondary">{{ b.size_h }}</td>
+189
View File
@@ -0,0 +1,189 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/Controller.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\Directories;
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\Content\Directories\DirectoryRepository;
use App\Helpers\Request;
class Controller extends BaseController
{
public function index(array $params = array())
{
if (!Permission::check('view_rubrics')) {
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
}
AdminAssets::addStyle($this->base() . '/modules/Rubrics/assets/rubrics.css', 49);
AdminAssets::addStyle($this->base() . '/modules/Directories/assets/directories.css', 50);
AdminAssets::addScript($this->base() . '/modules/Directories/assets/directories.js', 50);
$query = Request::getStr('q', '');
$directories = Model::all($query);
$selectedId = max(0, Request::getInt('id', 0));
$selected = $selectedId > 0 ? DirectoryRepository::find($selectedId) : null;
if ($selected) {
$selectedUsage = null;
foreach ($directories as $directory) {
if ((int) $directory['id'] === $selectedId) {
$selectedUsage = (int) $directory['usage_count'];
break;
}
}
$selected['usage_count'] = $selectedUsage === null
? DirectoryRepository::usageCount($selectedId)
: $selectedUsage;
}
return $this->render('@directories/index.twig', array(
'directories' => $directories,
'selected' => $selected,
'items' => $selected ? DirectoryRepository::items($selectedId) : array(),
'stats' => Model::stats(),
'schema_ready' => Model::schemaReady(),
'filters' => array('q' => $query),
'can_manage' => Permission::check('manage_rubrics'),
));
}
public function store(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
return $this->saveDirectory(0, 'Справочник создан', 'directory.created');
}
public function update(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
return $this->saveDirectory(isset($params['id']) ? (int) $params['id'] : 0, 'Справочник сохранён', 'directory.updated');
}
public function delete(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
$id = isset($params['id']) ? (int) $params['id'] : 0;
try {
if (!DirectoryRepository::delete($id)) {
return $this->error('Справочник не найден', array(), 404);
}
$this->audit('directory.deleted', $id);
return $this->success('Справочник удалён', array(
'redirect' => $this->base() . '/directories',
));
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
}
public function storeItem(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
return $this->saveItem(isset($params['id']) ? (int) $params['id'] : 0, 0, 'Значение добавлено', 'directory.item_created');
}
public function updateItem(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
return $this->saveItem(
isset($params['id']) ? (int) $params['id'] : 0,
isset($params['item']) ? (int) $params['item'] : 0,
'Значение сохранено',
'directory.item_updated'
);
}
public function deleteItem(array $params = array())
{
if (($error = $this->manageGuard()) !== null) {
return $error;
}
$directoryId = isset($params['id']) ? (int) $params['id'] : 0;
$itemId = isset($params['item']) ? (int) $params['item'] : 0;
if (!DirectoryRepository::deleteItem($directoryId, $itemId)) {
return $this->error('Значение не найдено', array(), 404);
}
$this->audit('directory.item_deleted', $directoryId, array('item_id' => $itemId));
return $this->success('Значение удалено', array(
'redirect' => $this->base() . '/directories?id=' . $directoryId,
));
}
protected function saveDirectory($id, $message, $action)
{
try {
$directory = DirectoryRepository::save($id, Request::postAll());
$this->audit($action, $directory['id'], array('code' => $directory['code']));
return $this->success($message, array(
'data' => $directory,
'redirect' => $this->base() . '/directories?id=' . $directory['id'],
));
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
}
protected function saveItem($directoryId, $itemId, $message, $action)
{
try {
$item = DirectoryRepository::saveItem($directoryId, $itemId, Request::postAll());
$this->audit($action, $directoryId, array('item_id' => $item['id'], 'key' => $item['item_key']));
return $this->success($message, array(
'data' => $item,
'redirect' => $this->base() . '/directories?id=' . $directoryId,
));
} catch (\Throwable $e) {
return $this->error($e->getMessage(), array(), 422);
}
}
protected function manageGuard()
{
return $this->guardPermission('manage_rubrics');
}
protected function audit($action, $directoryId, array $meta = array())
{
AuditLog::record($action, array(
'actor_id' => Auth::id(),
'target_type' => 'directory',
'target_id' => (int) $directoryId,
'meta' => $meta,
));
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/Model.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\Directories;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Content\Directories\DirectoryRepository;
/** Admin adapter over the content-domain directory repository. */
class Model
{
public static function all($query = '')
{
$rows = DirectoryRepository::all($query);
$usage = DirectoryRepository::usageMap();
foreach ($rows as &$row) {
$row['usage_count'] = isset($usage[$row['id']]) ? (int) $usage[$row['id']] : 0;
}
unset($row);
return $rows;
}
public static function stats()
{
$rows = DirectoryRepository::all();
$stats = array('total' => count($rows), 'active' => 0, 'values' => 0);
foreach ($rows as $row) {
if ($row['is_active']) {
$stats['active']++;
}
$stats['values'] += (int) $row['items_count'];
}
return $stats;
}
public static function schemaReady()
{
return DirectoryRepository::schemaReady();
}
}
@@ -0,0 +1,150 @@
.content-model-tabs {
margin-bottom: var(--space-5);
}
.directories-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.directories-stat {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
}
.directories-stat b {
display: block;
font-size: 20px;
line-height: 1.1;
font-weight: 800;
}
.directories-stat div > span {
color: var(--text-secondary);
font-size: 13px;
}
.directories-panel [hidden],
#directoryDrawer [hidden],
#directoryItemDrawer [hidden] {
display: none !important;
}
.directories-filter .input-wrap {
max-width: 560px;
}
.directories-table {
min-width: 760px;
}
.directories-col-name {
width: 34%;
}
.directories-col-code {
width: 18%;
}
.directories-col-values {
width: 100px;
}
.directories-col-usage {
width: 150px;
}
.directories-col-state {
width: 120px;
}
.directories-col-actions {
width: 92px;
}
.directories-name {
display: grid;
gap: 3px;
min-width: 0;
}
.directories-name b,
.directories-name small {
overflow-wrap: anywhere;
white-space: normal;
}
.directories-name small {
color: var(--text-secondary);
font-size: 12px;
}
.directories-table tr.is-selected > td {
background: var(--blue-50);
}
.directories-values-card {
margin-top: var(--space-4);
}
.directories-head-actions,
.directories-actions {
flex-wrap: nowrap;
}
.directories-actions {
justify-content: flex-end;
width: max-content;
margin-left: auto;
}
.directories-action-values {
color: var(--blue-600);
}
.directories-action-edit {
color: var(--violet-600);
}
.directories-items-table {
min-width: 660px;
table-layout: fixed;
}
.directories-item-order {
width: 92px;
}
.directories-item-key {
width: 190px;
}
.directories-item-state {
width: 124px;
}
.directories-item-actions {
width: 104px;
}
.directories-items-table tr.is-muted {
opacity: 0.55;
}
@media (max-width: 640px) {
.directories-summary {
grid-template-columns: 1fr;
}
.directories-values-card .rubrics-section-head {
align-items: stretch;
flex-direction: column;
}
.directories-head-actions {
justify-content: flex-end;
width: 100%;
}
.directories-table tbody tr {
grid-template-columns: minmax(0, 1fr) auto;
}
.directories-table .directories-cell-name,
.directories-table .directories-cell-code {
grid-column: 1 / -1;
}
.directories-table .directories-cell-code,
.directories-table .directories-cell-values,
.directories-table .directories-cell-usage,
.directories-table .directories-cell-state,
.directories-table .directories-cell-actions {
display: grid;
gap: 4px;
align-content: start;
justify-items: start;
}
.directories-table td[data-label]::before {
content: attr(data-label);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.directories-table .directories-cell-actions {
align-content: end;
justify-items: end;
}
}
@@ -0,0 +1,179 @@
(function (window, document) {
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
Adminx.Directories = {
page: null,
directoryForm: null,
itemForm: null,
init: function () {
this.page = document.querySelector('[data-directories-page]');
if (!this.page) { return; }
this.directoryForm = document.querySelector('[data-directory-form]');
this.itemForm = document.querySelector('[data-directory-item-form]');
var self = this;
document.addEventListener('click', function (event) {
if (event.target.closest('[data-directory-new]')) { self.newDirectory(); return; }
var edit = event.target.closest('[data-directory-edit]');
if (edit) { self.editDirectory(edit); return; }
if (event.target.closest('[data-directory-delete]')) { self.deleteDirectory(); return; }
if (event.target.closest('[data-directory-item-new]')) { self.newItem(); return; }
var itemEdit = event.target.closest('[data-directory-item-edit]');
if (itemEdit) { self.editItem(itemEdit); return; }
var itemDelete = event.target.closest('[data-directory-item-delete]');
if (itemDelete) { self.deleteItem(itemDelete); return; }
});
if (this.directoryForm) {
this.directoryForm.addEventListener('submit', function (event) {
event.preventDefault();
var id = self.directoryForm.elements.id.value;
self.submit(self.base() + '/directories' + (id ? '/' + id : ''), self.directoryForm);
});
}
if (this.itemForm) {
this.itemForm.addEventListener('submit', function (event) {
event.preventDefault();
var directoryId = self.itemForm.getAttribute('data-directory');
var itemId = self.itemForm.elements.id.value;
self.submit(self.base() + '/directories/' + directoryId + '/items' + (itemId ? '/' + itemId : ''), self.itemForm);
});
}
},
base: function () {
return this.page.getAttribute('data-base') || '';
},
newDirectory: function () {
if (!this.directoryForm) { return; }
this.directoryForm.reset();
this.directoryForm.elements.id.value = '';
this.directoryForm.elements.is_active.checked = true;
this.directoryForm.setAttribute('data-usage', '0');
this.directoryForm.querySelector('[data-directory-delete]').hidden = true;
document.querySelector('[data-directory-form-title]').textContent = 'Новый справочник';
Adminx.Drawer.open('directoryDrawer');
},
editDirectory: function (button) {
if (!this.directoryForm) { return; }
this.directoryForm.reset();
this.directoryForm.elements.id.value = button.getAttribute('data-id') || '';
this.directoryForm.elements.name.value = button.getAttribute('data-name') || '';
this.directoryForm.elements.code.value = button.getAttribute('data-code') || '';
this.directoryForm.elements.description.value = button.getAttribute('data-description') || '';
this.directoryForm.elements.is_active.checked = button.getAttribute('data-active') === '1';
this.directoryForm.setAttribute('data-usage', button.getAttribute('data-usage') || '0');
this.directoryForm.querySelector('[data-directory-delete]').hidden = false;
document.querySelector('[data-directory-form-title]').textContent = 'Настройки справочника';
Adminx.Drawer.open('directoryDrawer');
},
deleteDirectory: function () {
if (!this.directoryForm) { return; }
var self = this;
var id = this.directoryForm.elements.id.value;
var name = this.directoryForm.elements.name.value;
var usage = parseInt(this.directoryForm.getAttribute('data-usage'), 10) || 0;
Adminx.Confirm.open({
kind: usage > 0 ? 'warning' : 'danger',
title: usage > 0 ? 'Справочник используется' : 'Удалить справочник?',
message: usage > 0
? 'Он подключён к полям: ' + usage + '. Сначала выберите для них другой источник значений.'
: '«' + name + '» и все его значения будут удалены без восстановления.',
confirmLabel: usage > 0 ? 'Понятно' : 'Удалить',
confirmClass: usage > 0 ? 'btn-secondary' : 'btn-danger',
onConfirm: function () {
if (usage > 0) { return; }
var data = new FormData();
data.append('_csrf', self.csrf());
self.submit(self.base() + '/directories/' + id + '/delete', data);
}
});
},
newItem: function () {
if (!this.itemForm) { return; }
this.itemForm.reset();
this.itemForm.elements.id.value = '';
this.itemForm.elements.sort_order.value = '0';
this.itemForm.elements.is_active.checked = true;
document.querySelector('[data-directory-item-title]').textContent = 'Новое значение';
Adminx.Drawer.open('directoryItemDrawer');
},
editItem: function (button) {
if (!this.itemForm) { return; }
this.itemForm.reset();
this.itemForm.elements.id.value = button.getAttribute('data-id') || '';
this.itemForm.elements.item_key.value = button.getAttribute('data-key') || '';
this.itemForm.elements.label.value = button.getAttribute('data-label') || '';
this.itemForm.elements.sort_order.value = button.getAttribute('data-order') || '0';
this.itemForm.elements.is_active.checked = button.getAttribute('data-active') === '1';
document.querySelector('[data-directory-item-title]').textContent = 'Изменить значение';
Adminx.Drawer.open('directoryItemDrawer');
},
deleteItem: function (button) {
var self = this;
var itemId = button.getAttribute('data-directory-item-delete');
var directoryId = this.itemForm ? this.itemForm.getAttribute('data-directory') : '';
Adminx.Confirm.open({
kind: 'danger',
title: 'Удалить значение?',
message: '«' + (button.getAttribute('data-label') || '') + '» исчезнет из выбора. Уже сохранённые документы сохранят ключ.',
confirmLabel: 'Удалить',
confirmClass: 'btn-danger',
onConfirm: function () {
var data = new FormData();
data.append('_csrf', self.csrf());
self.submit(self.base() + '/directories/' + directoryId + '/items/' + itemId + '/delete', data);
}
});
},
submit: function (url, body) {
var self = this;
Adminx.Loader.show();
var data = body instanceof FormData ? body : new FormData(body);
fetch(url, {
method: 'POST',
body: data,
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (response) {
return response.json().then(function (payload) {
if (!response.ok || !payload.success) {
throw new Error(payload.message || 'Не удалось сохранить данные');
}
return payload;
});
}).then(function (payload) {
Adminx.Toast.show(payload.message || 'Сохранено', 'success');
window.location.href = payload.redirect || window.location.href;
}).catch(function (error) {
Adminx.Toast.show(error.message || 'Ошибка запроса', 'danger');
}).finally(function () {
Adminx.Loader.hide();
});
},
csrf: function () {
var input = document.querySelector('[data-directory-form] [name="_csrf"]');
return input ? input.value : '';
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { Adminx.Directories.init(); });
} else {
Adminx.Directories.init();
}
})(window, document);
@@ -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>
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS `{{content_prefix}}_directories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`code` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`name` VARCHAR(190) NOT NULL,
`description` TEXT NULL,
`settings_json` TEXT NULL,
`is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_directory_code` (`code`),
KEY `idx_directory_active_name` (`is_active`, `name`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `{{content_prefix}}_directory_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`directory_id` INT UNSIGNED NOT NULL,
`item_key` VARCHAR(120) NOT NULL,
`label` VARCHAR(255) NOT NULL,
`sort_order` INT UNSIGNED NOT NULL DEFAULT 0,
`is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`parent_id` INT UNSIGNED NOT NULL DEFAULT 0,
`data_json` TEXT NULL,
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_directory_item_key` (`directory_id`, `item_key`(120)),
KEY `idx_directory_item_order` (`directory_id`, `sort_order`, `id`),
KEY `idx_directory_item_parent` (`directory_id`, `parent_id`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
+40
View File
@@ -0,0 +1,40 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/module.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.');
return array(
'code' => 'directories',
'name' => 'Справочники',
'version' => '0.2.1',
'requires' => array('rubrics'),
'routes' => array(
array('GET', '/directories', array(\App\Adminx\Directories\Controller::class, 'index'), array('permission' => 'view_rubrics')),
array('POST', '/directories', array(\App\Adminx\Directories\Controller::class, 'store'), array('permission' => 'manage_rubrics')),
array('POST', '/directories/{id}', array(\App\Adminx\Directories\Controller::class, 'update'), array('permission' => 'manage_rubrics')),
array('POST', '/directories/{id}/delete', array(\App\Adminx\Directories\Controller::class, 'delete'), array('permission' => 'manage_rubrics')),
array('POST', '/directories/{id}/items', array(\App\Adminx\Directories\Controller::class, 'storeItem'), array('permission' => 'manage_rubrics')),
array('POST', '/directories/{id}/items/{item}', array(\App\Adminx\Directories\Controller::class, 'updateItem'), array('permission' => 'manage_rubrics')),
array('POST', '/directories/{id}/items/{item}/delete', array(\App\Adminx\Directories\Controller::class, 'deleteItem'), array('permission' => 'manage_rubrics')),
),
'migrations' => array(
array('id' => '001_create_directories', 'file' => 'migrations/001_create_directories.sql'),
),
'view_globals' => array(
'module_code' => 'directories',
),
);
+212
View File
@@ -0,0 +1,212 @@
{% 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 }}/rubrics">Рубрики и поля</a><i class="ti ti-chevron-right"></i>
<span>Справочники</span>
</nav>
<div class="page-header">
<div class="between">
<div>
<h1>Справочники</h1>
<p class="text-secondary">Общие списки значений, которые можно подключать к нескольким полям.</p>
</div>
{% if can_manage and schema_ready %}
<button class="btn btn-primary" type="button" data-directory-new><i class="ti ti-plus"></i>Создать справочник</button>
{% endif %}
</div>
</div>
{% if not schema_ready %}
<div class="alert alert-warning">
<i class="ti ti-database-exclamation alert-ic"></i>
<div>
<b>Схема справочников ещё не готова</b>
<p>Примените миграции ядра в разделе «База данных → Миграции».</p>
</div>
</div>
{% endif %}
<div class="directories-summary">
<div class="card directories-stat"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-books"></i></span><div><b>{{ stats.total }}</b><span>Справочников</span></div></div>
<div class="card directories-stat"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-list-check"></i></span><div><b>{{ stats.values }}</b><span>Значений</span></div></div>
</div>
{% set content_model_section = 'directories' %}
{% include '@rubrics/_content_tabs.twig' %}
<section class="rubrics-panel directories-panel" data-directories-page data-base="{{ ADMINX_BASE }}">
<div class="section-header rubrics-panel-header">
<div class="section-icon"><i class="ti ti-books"></i></div>
<div>
<div class="section-eyebrow">Поля рубрик</div>
<h2>Общие справочники</h2>
<p class="section-desc">Один список можно подключить к нескольким полям и менять централизованно.</p>
</div>
</div>
<div class="card rubrics-card rubrics-filter-card">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-table"></i></span>
<div>
<h2>Справочники</h2>
<p class="text-secondary">{{ directories|length }} в текущем фильтре, {{ stats.total }} всего.</p>
</div>
</div>
<span class="badge badge-blue">{{ directories|length }}</span>
</div>
<div class="table-toolbar rubrics-toolbar">
<form class="rubrics-filter directories-filter" method="get" action="{{ ADMINX_BASE }}/directories">
<div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Название или код"></div>
<button class="btn btn-secondary" type="submit"><i class="ti ti-search"></i>Найти</button>
{% if filters.q %}<a class="btn btn-ghost" href="{{ ADMINX_BASE }}/directories"><i class="ti ti-x"></i>Сбросить</a>{% endif %}
</form>
</div>
</div>
<div class="card rubrics-card">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-details"></i></span>
<div>
<h2>Список справочников</h2>
<p class="text-secondary">Ключ хранится в документе, подпись можно менять без перезаписи документов.</p>
</div>
</div>
</div>
<div class="table-scroll">
<table class="table table-compact rubrics-table directories-table">
<colgroup>
<col class="directories-col-name">
<col class="directories-col-code">
<col class="directories-col-values">
<col class="directories-col-usage">
<col class="directories-col-state">
<col class="directories-col-actions">
</colgroup>
<thead><tr><th>Название</th><th>Код</th><th>Значения</th><th>Используется</th><th>Состояние</th><th></th></tr></thead>
<tbody>
{% for item in directories %}
<tr{{ selected and selected.id == item.id ? ' class="is-selected"' : '' }}>
<td class="directories-cell-name">
<div class="directories-name">
<b>{{ item.name }}</b>
{% if item.description %}<small>{{ item.description }}</small>{% endif %}
</div>
</td>
<td class="directories-cell-code" data-label="Код"><code>{{ item.code }}</code></td>
<td class="directories-cell-values" data-label="Значения">{{ item.items_count }}</td>
<td class="directories-cell-usage" data-label="Используется">{% if item.usage_count %}<span class="badge badge-blue">{{ item.usage_count }} полей</span>{% else %}<span class="text-secondary">Не подключён</span>{% endif %}</td>
<td class="directories-cell-state" data-label="Состояние">{% if item.is_active %}<span class="badge badge-green">активен</span>{% else %}<span class="badge badge-gray">выключен</span>{% endif %}</td>
<td class="directories-cell-actions">
<div class="cluster directories-actions">
<a class="btn btn-ghost btn-icon btn-sm directories-action-values" href="{% if selected and selected.id == item.id %}#directory-values{% else %}{{ ADMINX_BASE }}/directories?id={{ item.id }}{% if filters.q %}&q={{ filters.q|url_encode }}{% endif %}#directory-values{% endif %}" data-tooltip="Открыть значения" aria-label="Открыть значения"><i class="ti ti-list-details"></i></a>
{% if can_manage %}
<button class="btn btn-ghost btn-icon btn-sm directories-action-edit" type="button" data-directory-edit
data-id="{{ item.id }}" data-name="{{ item.name|e('html_attr') }}" data-code="{{ item.code|e('html_attr') }}"
data-description="{{ item.description|e('html_attr') }}" data-active="{{ item.is_active ? '1' : '0' }}" data-usage="{{ item.usage_count }}"
data-tooltip="Настроить" aria-label="Настроить"><i class="ti ti-settings"></i></button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td class="empty-state-cell" colspan="6"><div class="empty-state"><i class="ti ti-books"></i><b>Справочников пока нет</b><span>Создайте общий список значений для полей.</span></div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if selected %}
<div class="card rubrics-card directories-values-card" id="directory-values">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-list-check"></i></span>
<div>
<h2>Значения: {{ selected.name }}</h2>
<p class="text-secondary"><code>{{ selected.code }}</code> · {{ selected.usage_count }} полей · {{ items|length }} значений</p>
</div>
</div>
{% if can_manage %}
<div class="cluster directories-head-actions">
<button class="btn btn-secondary btn-icon" type="button" data-directory-edit
data-id="{{ selected.id }}" data-name="{{ selected.name|e('html_attr') }}" data-code="{{ selected.code|e('html_attr') }}"
data-description="{{ selected.description|e('html_attr') }}" data-active="{{ selected.is_active ? '1' : '0' }}" data-usage="{{ selected.usage_count }}"
data-tooltip="Настроить справочник" aria-label="Настроить справочник"><i class="ti ti-settings"></i></button>
<button class="btn btn-primary" type="button" data-directory-item-new><i class="ti ti-plus"></i>Добавить значение</button>
</div>
{% endif %}
</div>
<div class="table-scroll">
<table class="table table-compact directories-items-table">
<colgroup><col class="directories-item-order"><col class="directories-item-key"><col><col class="directories-item-state"><col class="directories-item-actions"></colgroup>
<thead><tr><th>Порядок</th><th>Ключ</th><th>Подпись</th><th>Состояние</th><th></th></tr></thead>
<tbody>
{% for value in items %}
<tr{{ value.is_active ? '' : ' class="is-muted"' }}>
<td class="mono text-muted">{{ value.sort_order }}</td>
<td><code>{{ value.item_key }}</code></td>
<td><b>{{ value.label }}</b></td>
<td>{% if value.is_active %}<span class="badge badge-green">активно</span>{% else %}<span class="badge badge-gray">выключено</span>{% endif %}</td>
<td><div class="cluster directories-actions">
{% if can_manage %}
<button class="btn btn-ghost btn-icon btn-sm directories-action-edit" type="button" data-directory-item-edit
data-id="{{ value.id }}" data-key="{{ value.item_key|e('html_attr') }}" data-label="{{ value.label|e('html_attr') }}"
data-order="{{ value.sort_order }}" data-active="{{ value.is_active ? '1' : '0' }}"
data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>
<button class="btn btn-danger-soft btn-icon btn-sm" type="button" data-directory-item-delete="{{ value.id }}" data-label="{{ value.label|e('html_attr') }}" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
{% endif %}
</div></td>
</tr>
{% else %}
<tr><td class="empty-state-cell" colspan="5"><div class="empty-state"><i class="ti ti-list"></i><b>Значений пока нет</b><span>Добавьте первую пару «ключ → подпись».</span></div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</section>
{% if can_manage %}
<aside class="drawer drawer-right" id="directoryDrawer" role="dialog" aria-modal="true" aria-labelledby="directoryDrawerTitle" hidden>
<form data-directory-form>
<div class="drawer-header"><div><h3 id="directoryDrawerTitle" data-directory-form-title>Новый справочник</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">
<input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="id" value="">
<div class="form-grid">
<label class="field col-12"><span class="field-label">Название</span><div class="input-wrap"><i class="ti ti-book-2"></i><input class="input" name="name" required maxlength="190" placeholder="Например, Производители"></div></label>
<label class="field col-12"><span class="field-label">Код</span><div class="input-wrap"><i class="ti ti-code"></i><input class="input mono" name="code" maxlength="64" pattern="[a-z0-9][a-z0-9_-]{1,63}" placeholder="manufacturers"></div><span class="field-hint">Стабильный технический код. Если оставить пустым, сформируется из названия.</span></label>
<label class="field col-12"><span class="field-label">Описание</span><textarea class="textarea" name="description" rows="4" placeholder="Где и для чего используется список"></textarea></label>
<div class="field col-12"><span class="field-label">Состояние</span><label class="switch"><input type="checkbox" name="is_active" value="1" checked><span class="switch-track"></span><span class="switch-label">Доступен для выбора в полях</span></label></div>
</div>
</div>
<div class="drawer-footer"><button class="btn btn-danger-soft" type="button" data-directory-delete hidden><i class="ti ti-trash"></i>Удалить</button><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit" style="margin-left:auto"><i class="ti ti-device-floppy"></i>Сохранить</button></div>
</form>
</aside>
{% if selected %}
<aside class="drawer drawer-right" id="directoryItemDrawer" role="dialog" aria-modal="true" aria-labelledby="directoryItemDrawerTitle" hidden>
<form data-directory-item-form data-directory="{{ selected.id }}">
<div class="drawer-header"><div><h3 id="directoryItemDrawerTitle" data-directory-item-title>Новое значение</h3><p class="text-secondary text-sm">{{ selected.name }}</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">
<input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="id" value="">
<div class="form-grid">
<label class="field col-12"><span class="field-label">Подпись</span><div class="input-wrap"><i class="ti ti-forms"></i><input class="input" name="label" required maxlength="255" placeholder="Например, Россия"></div><span class="field-hint">Эту подпись увидят редактор и посетитель сайта.</span></label>
<label class="field col-8"><span class="field-label">Стабильный ключ</span><div class="input-wrap"><i class="ti ti-key"></i><input class="input mono" name="item_key" maxlength="120" pattern="[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}" placeholder="russia"></div><span class="field-hint">Хранится в документе. После начала использования ключ лучше не менять.</span></label>
<label class="field col-4"><span class="field-label">Порядок</span><input class="input" type="number" min="0" name="sort_order" value="0"></label>
<div class="field col-12"><span class="field-label">Состояние</span><label class="switch"><input type="checkbox" name="is_active" value="1" checked><span class="switch-track"></span><span class="switch-label">Показывать в полях</span></label></div>
</div>
</div>
<div class="drawer-footer"><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit" style="margin-left:auto"><i class="ti ti-device-floppy"></i>Сохранить</button></div>
</form>
</aside>
{% endif %}
{% endif %}
{% endblock %}
+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';
}
}
+151 -26
View File
@@ -21,9 +21,11 @@
use App\Common\AuditLog;
use App\Common\Auth;
use App\Common\Controller as BaseController;
use App\Adminx\Support\BulkActionExecutor;
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;
@@ -36,12 +38,16 @@
use App\Content\Documents\DocumentSnapshotStore;
use App\Content\Documents\FieldTemplateManifest;
use App\Content\Documents\RubricSchemaBuilder;
use App\Content\Fields\DocumentRelationIndex;
use App\Frontend\DocumentRevisionPreview;
use App\Helpers\Request;
use DB;
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);
@@ -82,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')) {
@@ -174,7 +199,11 @@
AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50);
AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50);
return $this->render('@documents/api.twig', array('tokens' => ApiTokenRepository::all(), 'can_manage_api' => true));
$tokens = array_values(array_filter(ApiTokenRepository::all(), function ($token) {
return ApiTokenRepository::hasScope($token, 'documents:read')
|| ApiTokenRepository::hasScope($token, 'documents:write');
}));
return $this->render('@documents/api.twig', array('tokens' => $tokens, 'can_manage_api' => true));
}
public function issueApiToken(array $params = array())
@@ -184,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); }
@@ -195,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-токен отозван');
}
@@ -240,6 +278,7 @@
'can_manage' => true,
'quick_edit' => Request::getBool('quick_edit', false),
'actor_id' => Auth::id(),
'document_relations' => DocumentRelationIndex::describe((int) $item['Id']),
));
}
@@ -357,27 +396,95 @@
public function bulk(array $params = array())
{
if (($err = $this->guard()) !== null) { return $err; }
$postedIds = Request::post('ids', array());
$ids = is_array($postedIds) ? array_values(array_unique(array_map('intval', $postedIds))) : array();
$ids = array_values(array_filter($ids, function ($id) { return $id > 0; }));
if (empty($ids) || count($ids) > 200) { return $this->error('Выберите от 1 до 200 документов', array(), 422); }
$action = Request::postStr('action', '');
if (!in_array($action, array('publish', 'unpublish', 'delete', 'restore', 'purge'), true)) { return $this->error('Неизвестное пакетное действие', array(), 422); }
$done = 0; $errors = array();
foreach ($ids as $id) {
try {
$handler = function ($id) use ($action) {
$doc = Model::one($id);
if (!$doc) { continue; }
if (!$doc) { return false; }
if ($action === 'publish' && empty($doc['document_status'])) { Model::toggleStatus($id); }
elseif ($action === 'unpublish' && !empty($doc['document_status'])) { Model::toggleStatus($id); }
elseif ($action === 'delete') { Model::delete($id); }
elseif ($action === 'restore') { Model::restore($id); }
elseif ($action === 'purge') { Model::purge($id); }
$done++;
} catch (\Throwable $e) { $errors[] = '#' . $id . ': ' . $e->getMessage(); }
return true;
};
try {
$result = BulkActionExecutor::execute($action, Request::post('ids', array()), array(
'publish' => $handler,
'unpublish' => $handler,
'delete' => $handler,
'restore' => $handler,
'purge' => $handler,
), 200);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), array(), 422);
}
return $this->success('Пакетное действие выполнено', array('data' => array('done' => $done, 'errors' => $errors)));
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())
@@ -389,7 +496,9 @@
$input = Model::prepareAliasInput(Request::postAll(), 0);
$fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array();
$rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0;
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0);
$fieldGroups = Model::fieldsForRubric($rubricId, 0);
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0, $fieldGroups);
$fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, 0, $fieldGroups);
$input['fields'] = $fieldValues;
try {
DocumentSaveEvents::before('create', 'adminx', 0, $rubricId, Auth::id(), $input, $fieldValues);
@@ -400,11 +509,12 @@
return $this->error('Код рубрики остановил создание документа', array('rubric_code_start' => $e->getMessage()), 422);
}
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0);
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0, $fieldGroups);
$fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, 0, $fieldGroups);
$input['fields'] = $fieldValues;
$input = Model::prepareAliasInput($input, 0);
$errors = $this->validate($input, 0);
$errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0));
$errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0, $fieldGroups));
if (!empty($errors)) {
return $this->error('Проверьте поля документа', $errors, 422);
}
@@ -428,7 +538,8 @@
Model::save($id, $input, Auth::id());
}
Model::saveFields($id, $fieldValues);
Model::saveFields($id, $fieldValues, $fieldGroups);
DocumentSaveEvents::persisted('create', 'adminx', $id, $rubricId, Auth::id(), $input, $fieldValues);
DB::commit();
$mediaFinalization->commit();
} catch (DocumentHookException $e) {
@@ -490,13 +601,15 @@
$input = Model::prepareAliasInput(Request::postAll(), $id);
$rubricId = $document ? (int) $document['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0);
$fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array();
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id);
$fieldGroups = Model::fieldsForRubric($rubricId, $id);
$fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id, $fieldGroups);
$fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, $id, $fieldGroups);
$input['fields'] = $fieldValues;
$errors = $this->validate($input, $id);
$errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id));
$errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id, $fieldGroups));
return $this->success('Данные сформированы без сохранения', array('data' => array(
'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id()),
'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id(), $fieldGroups),
'validation_errors' => $errors,
)));
}
@@ -520,7 +633,9 @@
}
$fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array();
$fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id);
$fieldGroups = Model::fieldsForRubric((int) $doc['rubric_id'], $id);
$fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups);
$fieldValues = Model::computedFieldValues((int) $doc['rubric_id'], $fieldValues, $input, $id, $fieldGroups);
$input['fields'] = $fieldValues;
try {
DocumentSaveEvents::before('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc);
@@ -531,14 +646,21 @@
return $this->error('Код рубрики остановил сохранение документа', array('rubric_code_start' => $e->getMessage()), 422);
}
$fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id);
$fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups);
$fieldValues = Model::computedFieldValues((int) $doc['rubric_id'], $fieldValues, $input, $id, $fieldGroups);
$input['fields'] = $fieldValues;
$errors = $this->validate($input, $id);
$errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id));
$errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups));
if (!empty($errors)) {
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;
@@ -559,7 +681,8 @@
Model::save($id, $input, Auth::id());
}
Model::saveFields($id, $fieldValues);
Model::saveFields($id, $fieldValues, $fieldGroups);
DocumentSaveEvents::persisted('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc);
DB::commit();
$mediaFinalization->commit();
} catch (EditConflict $e) {
@@ -601,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);
}
}
+114 -84
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;
@@ -32,10 +33,12 @@
use App\Content\Documents\DocumentAliasTemplate;
use App\Content\Documents\DocumentMediaDraft;
use App\Content\Documents\DocumentMediaPath;
use App\Content\Documents\DocumentSearch;
use App\Common\Settings;
use App\Common\SystemTables;
use App\Common\Lifecycle;
use App\Common\DatabaseSchema;
use App\Adminx\Support\AdminLocale;
use App\Adminx\Rubrics\FieldAdminEditors;
use App\Adminx\Catalog\Model as CatalogModel;
@@ -63,26 +66,29 @@
$offset = ($page - 1) * $limit;
$where = ' WHERE 1=1';
$args = array();
$score = '0';
$scoreArgs = array();
$rubricId = isset($filters['rubric_id']) ? (int) $filters['rubric_id'] : 0;
$q = trim(isset($filters['q']) ? (string) $filters['q'] : '');
$field = self::normalizeSearchField(isset($filters['field']) ? (string) $filters['field'] : '', $rubricId);
if ($q !== '') {
if ($field !== '') {
$criteria = DocumentSearch::valueCriteria($q, array('df.field_value', 'dft.field_value'));
// Поиск по значению выбранного поля рубрики (напр. «Артикул»).
$where .= ' AND EXISTS (SELECT 1 FROM ' . self::docFieldsTable() . ' df'
. ' INNER JOIN ' . self::fieldsTable() . ' rf ON rf.Id = df.rubric_field_id'
. ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id'
. ' WHERE df.document_id = d.Id AND rf.rubric_field_title = %s'
. ' AND (df.field_value LIKE %ss OR dft.field_value LIKE %ss))';
. ' AND ' . $criteria['where'] . ')';
$args[] = $field;
$args[] = $q;
$args[] = $q;
$args = array_merge($args, $criteria['where_args']);
} else {
$where .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)';
$args[] = $q;
$args[] = $q;
$args[] = (int) $q;
$criteria = DocumentSearch::criteria($q, 'd');
$where .= ' AND ' . $criteria['where'];
$args = array_merge($args, $criteria['where_args']);
$score = $criteria['score'];
$scoreArgs = $criteria['score_args'];
}
}
@@ -105,12 +111,16 @@
$countArgs = array_merge(array('SELECT COUNT(*) FROM ' . self::documentsTable() . ' d' . $where), $args);
$total = (int) call_user_func_array(array('DB', 'query'), $countArgs)->getValue();
$sql = 'SELECT d.*, r.rubric_title, r.rubric_alias'
$sql = 'SELECT d.*, r.rubric_title, r.rubric_alias,(' . $score . ') AS search_relevance'
. ' FROM ' . self::documentsTable() . ' d'
. ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id'
. $where
. ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset;
$rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll();
. ' ORDER BY search_relevance DESC,d.document_changed DESC,d.Id DESC'
. ' LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset;
$rows = call_user_func_array(
array('DB', 'query'),
array_merge(array($sql), $scoreArgs, $args)
)->getAll();
$items = array();
foreach ($rows as $row) {
@@ -321,7 +331,7 @@
* Валидация значений полей документа по JSON-настройкам (rubric_field_settings.rules).
* Возвращает ошибки в формате ['fields[<Id>]' => сообщение]. Пусто, если правил нет.
*/
public static function validateFieldValues($rubricId, array $values, $documentId = 0)
public static function validateFieldValues($rubricId, array $values, $documentId = 0, $fieldGroups = null)
{
$rubricId = (int) $rubricId;
if ($rubricId <= 0) {
@@ -330,7 +340,7 @@
$fields = array();
$effectiveValues = array();
foreach (self::fieldsForRubric($rubricId, (int) $documentId) as $group) {
foreach (self::resolveFieldGroups($rubricId, (int) $documentId, $fieldGroups) as $group) {
foreach ($group['items'] as $field) {
$fields[] = $field;
$effectiveValues[(int) $field['Id']] = isset($field['field_value']) ? $field['field_value'] : '';
@@ -344,8 +354,34 @@
return FieldValidator::validateValues($fields, $effectiveValues, self::rubricConditionsEnabled($rubricId));
}
public static function computedFieldValues($rubricId, array $values, array $document = array(), $documentId = 0, $fieldGroups = null)
{
$definitions = array();
$effectiveValues = array();
foreach (self::resolveFieldGroups((int) $rubricId, (int) $documentId, $fieldGroups) as $group) {
foreach ($group['items'] as $field) {
$fieldId = (int) $field['Id'];
$definitions[$fieldId] = $field;
$effectiveValues[$fieldId] = isset($field['field_value']) ? $field['field_value'] : '';
}
}
foreach ($values as $fieldId => $value) {
$effectiveValues[(int) $fieldId] = $value;
}
$computedValues = \App\Content\Fields\ComputedFieldEvaluator::apply($definitions, $effectiveValues, $document);
foreach ($definitions as $fieldId => $field) {
if ((string) $field['rubric_field_type'] === 'computed' && array_key_exists($fieldId, $computedValues)) {
$values[$fieldId] = $computedValues[$fieldId];
}
}
return $values;
}
/** Remove values of fields hidden or locked by rubric form conditions. */
public static function conditionalFieldValues($rubricId, array $values, $documentId = 0)
public static function conditionalFieldValues($rubricId, array $values, $documentId = 0, $fieldGroups = null)
{
if (!self::rubricConditionsEnabled((int) $rubricId)) {
return $values;
@@ -355,7 +391,7 @@
$fieldMap = array();
$storedValues = array();
$effectiveValues = array();
foreach (self::fieldsForRubric((int) $rubricId, (int) $documentId) as $group) {
foreach (self::resolveFieldGroups((int) $rubricId, (int) $documentId, $fieldGroups) as $group) {
foreach ($group['items'] as $field) {
$fields[] = $field;
$fieldId = (int) $field['Id'];
@@ -406,7 +442,7 @@
return array('raw' => $value);
}
public static function saveFields($documentId, array $values)
public static function saveFields($documentId, array $values, $fieldGroups = null)
{
$documentId = (int) $documentId;
$doc = self::one($documentId);
@@ -414,8 +450,9 @@
throw new \RuntimeException('Документ не найден');
}
$values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId);
$fields = self::fieldsForRubric((int) $doc['rubric_id'], $documentId);
$fields = self::resolveFieldGroups((int) $doc['rubric_id'], $documentId, $fieldGroups);
$values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId, $fields);
$values = self::computedFieldValues((int) $doc['rubric_id'], $values, $doc, $documentId, $fields);
$catalogAllowed = array();
$catalogFields = array();
foreach ($fields as $group) {
@@ -492,14 +529,16 @@
throw new \RuntimeException('Документ не найден');
}
$values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId);
$errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId);
$fieldGroups = self::fieldsForRubric((int) $doc['rubric_id'], $documentId);
$values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId, $fieldGroups);
$values = self::computedFieldValues((int) $doc['rubric_id'], $values, $doc, $documentId, $fieldGroups);
$errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId, $fieldGroups);
if ($errors) {
throw new \RuntimeException('Поля документа не прошли проверку: ' . implode('; ', array_values($errors)));
}
$fieldMap = array();
foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) {
foreach ($fieldGroups as $group) {
foreach ($group['items'] as $field) {
$fieldMap[(int) $field['Id']] = $field;
}
@@ -683,7 +722,7 @@
);
}
public static function previewPayload($id, array $input, array $values, $authorId)
public static function previewPayload($id, array $input, array $values, $authorId, $fieldGroups = null)
{
$id = (int) $id;
$existing = $id > 0 ? self::one($id) : null;
@@ -716,7 +755,7 @@
$fields = array();
$skippedFields = array();
$fieldRows = self::fieldsForRubric($rubricId, $id);
$fieldRows = self::resolveFieldGroups($rubricId, $id, $fieldGroups);
$flatFields = array();
foreach ($fieldRows as $group) {
foreach ($group['items'] as $field) {
@@ -775,6 +814,13 @@
);
}
protected static function resolveFieldGroups($rubricId, $documentId, $fieldGroups)
{
return is_array($fieldGroups)
? $fieldGroups
: self::fieldsForRubric((int) $rubricId, (int) $documentId);
}
public static function delete($id)
{
$id = (int) $id;
@@ -903,17 +949,37 @@
throw new \RuntimeException('Сначала переместите документ в корзину');
}
DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id);
DB::Delete(self::docFieldsTable(), 'document_id = %i', $id);
DB::Delete(self::revisionsTable(), 'doc_id = %i', $id);
DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id);
DB::Delete(self::keywordsTable(), 'document_id = %i', $id);
DB::Delete(self::tagsTable(), 'document_id = %i', $id);
DB::Delete(self::remarksTable(), 'document_id = %i', $id);
DB::Delete(self::viewCountTable(), 'document_id = %i', $id);
DB::Delete(self::documentsTable(), 'Id = %i', $id);
$ownsTransaction = !DB::$transaction_in_progress;
if ($ownsTransaction) { DB::startTransaction(); }
try {
DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id);
DB::Delete(self::docFieldsTable(), 'document_id = %i', $id);
DB::Delete(self::revisionsTable(), 'doc_id = %i', $id);
DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id);
DB::Delete(self::keywordsTable(), 'document_id = %i', $id);
DB::Delete(self::tagsTable(), 'document_id = %i', $id);
DB::Delete(self::remarksTable(), 'document_id = %i', $id);
DB::Delete(self::viewCountTable(), 'document_id = %i', $id);
\App\Content\Fields\DocumentRelationIndex::removeDocument($id);
DB::Delete(self::documentsTable(), 'Id = %i', $id);
if ($ownsTransaction) { DB::commit(); }
} catch (\Throwable $e) {
if ($ownsTransaction) { DB::rollback(); }
throw $e;
}
self::clearDocumentCache($id);
CatalogModel::reindexDocument($id);
Lifecycle::event(
'content.document.deleted',
'document',
'deleted',
$id,
array(),
true,
array('hard_delete' => true),
'adminx_documents'
);
return true;
}
@@ -1262,45 +1328,7 @@
public static function documentPicker($q, $rubricId = 0, $limit = 20)
{
$limit = max(1, min(50, (int) $limit));
$sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, r.rubric_title'
. ' FROM ' . self::documentsTable() . ' d'
. ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id'
. " WHERE d.document_deleted != '1'";
$args = array();
$q = trim((string) $q);
if ($q !== '') {
$sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)';
$args[] = $q;
$args[] = $q;
$args[] = (int) $q;
}
$rubricIds = array();
foreach (explode(',', (string) $rubricId) as $r) {
$r = (int) trim($r);
if ($r > 0) { $rubricIds[] = $r; }
}
if (!empty($rubricIds)) {
$rubricIds = array_values(array_unique($rubricIds));
$sql .= ' AND d.rubric_id IN (' . implode(',', array_map('intval', $rubricIds)) . ')';
}
$sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit;
$rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll();
$out = array();
foreach ($rows as $row) {
$out[] = array(
'id' => (int) $row['Id'],
'rubric_id' => (int) $row['rubric_id'],
'title' => self::decode($row['document_title']),
'alias' => (string) $row['document_alias'],
'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''),
);
}
return $out;
return (new \App\Content\Documents\DocumentPickerRepository())->search($q, $rubricId, $limit);
}
/** Existing keywords/tags for the searchable tag inputs in document editor. */
@@ -1653,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';
}
@@ -1670,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'] : '',
@@ -2098,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',
);
@@ -2185,14 +2215,17 @@
}
}
$emptyLabel = AdminLocale::translateMarkup('Разделы не выбраны.');
$addLabel = AdminLocale::translateMarkup('Добавить раздел');
$searchLabel = AdminLocale::translateMarkup('Найти раздел');
$html = '<div class="documents-catalog-field" data-document-catalog-field="' . $id . '" data-catalog-limits-fields="' . ((int) $settings['doc_fileds'] === 1 ? '1' : '0') . '">'
. '<input type="hidden" name="fields[' . $id . '][catalog_ids][]" value="">'
. '<div class="documents-catalog-tokens" data-document-catalog-tokens>' . $tokens . '</div>'
. '<p class="documents-catalog-empty"' . (!empty($selectedIds) ? ' hidden' : '') . ' data-document-catalog-empty>Разделы не выбраны.</p>'
. '<p class="documents-catalog-empty"' . (!empty($selectedIds) ? ' hidden' : '') . ' data-document-catalog-empty>' . $emptyLabel . '</p>'
. '<div class="dropdown documents-catalog-dropdown">'
. '<button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>Добавить раздел</button>'
. '<button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>' . $addLabel . '</button>'
. '<div class="dropdown-menu documents-catalog-menu">'
. '<label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-document-catalog-search></label>'
. '<label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="' . $searchLabel . '" data-document-catalog-search></label>'
. '<ol class="documents-catalog-tree">' . self::renderCatalogNodes($tree, $id, $selected) . '</ol>'
. '</div></div></div>';
return $html;
@@ -2213,18 +2246,21 @@
protected static function catalogTokenHtml($fieldId, $catalogId, $name, $fields)
{
$removeLabel = AdminLocale::translateMarkup('Убрать раздел');
return '<span class="documents-catalog-token" data-document-catalog-token data-catalog-id="' . (int) $catalogId
. '" data-catalog-fields="' . self::e($fields) . '">'
. '<i class="ti ti-folder documents-catalog-token-icon" aria-hidden="true"></i>'
. '<span class="documents-catalog-token-name">' . self::e($name) . '</span>'
. '<input type="hidden" name="fields[' . (int) $fieldId . '][catalog_ids][]" value="' . (int) $catalogId . '">'
. '<button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="Убрать раздел"><i class="ti ti-x"></i></button>'
. '<button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="' . $removeLabel . '"><i class="ti ti-x"></i></button>'
. '</span>';
}
protected static function renderCatalogNodes(array $items, $fieldId, array $selected)
{
$html = '';
$documentLabel = AdminLocale::translateMarkup('документ #');
$hiddenLabel = AdminLocale::translateMarkup('скрыт');
foreach ($items as $item) {
$fields = implode(',', isset($item['fields_use']) ? $item['fields_use'] : array());
$isSelected = isset($selected[$item['id']]);
@@ -2232,8 +2268,8 @@
. '<button class="documents-catalog-option' . ($isSelected ? ' is-picked' : '') . '" type="button" data-document-catalog-option'
. ' data-catalog-id="' . (int) $item['id'] . '" data-catalog-name="' . self::e($item['name']) . '" data-catalog-fields="' . self::e($fields) . '">'
. '<span class="documents-catalog-option-main"><b>' . self::e($item['name']) . '</b><small>#' . (int) $item['id']
. ($item['document_id'] > 0 ? ' · документ #' . (int) $item['document_id'] : '') . '</small></span>'
. ((int) $item['status'] === 1 ? '' : '<span class="badge badge-gray">скрыт</span>') . '</button>';
. ($item['document_id'] > 0 ? ' · ' . $documentLabel . (int) $item['document_id'] : '') . '</small></span>'
. ((int) $item['status'] === 1 ? '' : '<span class="badge badge-gray">' . $hiddenLabel . '</span>') . '</button>';
if (!empty($item['children'])) { $html .= '<ol>' . self::renderCatalogNodes($item['children'], $fieldId, $selected) . '</ol>'; }
$html .= '</li>';
}
@@ -2546,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,
);
}
+402 -27
View File
@@ -19,7 +19,6 @@
font-size: 20px;
line-height: 1.1;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.documents-stat span {
font-size: 13px;
@@ -124,7 +123,6 @@
}
.documents-term-option .co-sub {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.documents-term-status {
display: flex;
@@ -512,26 +510,27 @@
width: 7ch;
}
.documents-col-title {
width: 32%;
width: auto;
}
.documents-col-check {
width: 44px;
}
.documents-col-rubric {
width: 22%;
width: 170px;
}
.documents-col-state {
width: 18%;
width: 144px;
}
.documents-col-date {
width: 18%;
width: 152px;
}
.documents-col-actions {
width: 184px;
width: 164px;
}
.documents-name,
.documents-meta {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 3px;
min-width: 0;
}
@@ -542,6 +541,15 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.documents-cell-title .documents-name b {
display: block;
max-width: 100%;
overflow: hidden;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
line-height: 1.35;
}
.documents-title-meta {
display: flex;
flex-wrap: wrap;
@@ -558,7 +566,10 @@
min-width: 0;
}
.documents-title-meta .mono {
overflow: hidden;
color: var(--text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.documents-search-flag {
display: inline-flex;
@@ -591,6 +602,9 @@
.documents-actions .btn {
text-decoration: none;
}
.documents-cell-actions .documents-actions {
justify-content: flex-end;
}
.documents-action-edit {
color: var(--blue-600);
}
@@ -1154,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;
@@ -1184,20 +1436,43 @@
.documents-edit-header h1 {
text-wrap: balance;
}
.documents-editor-modes {
flex: 0 0 auto;
.documents-workspace-navigation {
margin-bottom: 16px;
}
.documents-editor-modes .segmented-item {
min-height: 36px;
gap: 6px;
.documents-workspace-tabs {
overflow: visible;
}
.documents-edit-form[data-editor-mode="quick"] [data-editor-level="normal"],
.documents-edit-form[data-editor-mode="quick"] [data-editor-level="advanced"],
.documents-edit-form[data-editor-mode="normal"] [data-editor-level="advanced"] {
.documents-workspace-tabs .tab {
min-height: 40px;
}
.documents-workspace-tabs .tab[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.documents-workspace-mobile {
display: none;
}
.documents-edit-form[data-editor-mode="quick"] .documents-edit-layout {
grid-template-columns: minmax(0, 1fr);
.documents-editor-workspace [data-document-workspace-panel] {
display: none;
}
.documents-editor-workspace[data-active-panel="main"] [data-document-section="additional"],
.documents-editor-workspace[data-active-panel="additional"] [data-document-section="main"] {
display: none;
}
.documents-editor-workspace[data-active-panel="attributes"] #documentForm,
.documents-editor-workspace[data-active-panel="promotions"] #documentForm,
.documents-editor-workspace[data-active-panel="variants"] #documentForm,
.documents-editor-workspace[data-active-panel="shipping"] #documentForm {
display: none;
}
.documents-editor-workspace[data-active-panel="attributes"] [data-document-workspace-panel="attributes"],
.documents-editor-workspace[data-active-panel="promotions"] [data-document-workspace-panel="promotions"],
.documents-editor-workspace[data-active-panel="variants"] [data-document-workspace-panel="variants"],
.documents-editor-workspace[data-active-panel="shipping"] [data-document-workspace-panel="shipping"] {
display: block;
}
.documents-editor-workspace [data-document-workspace-panel] {
margin-bottom: 0;
}
.documents-draft-recovery,
.documents-error-summary {
@@ -1358,9 +1633,6 @@
.documents-field-tabs .tab[hidden] {
display: none;
}
.documents-field-tabs .tab-count {
font-variant-numeric: tabular-nums;
}
.documents-field-tabs .tab-hint {
margin-left: 2px;
font-size: 14px;
@@ -1602,7 +1874,6 @@
font-family: var(--font-mono, ui-monospace, "SFMono-Regular", monospace);
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.ax-attr-type {
display: none;
@@ -1781,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;
@@ -1925,7 +2217,6 @@
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.field-count.is-over {
color: var(--red-600);
@@ -2029,7 +2320,6 @@
.documents-relation-empty {
color: var(--text-tertiary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.documents-relation-empty {
margin: 0;
@@ -2106,7 +2396,6 @@
white-space: nowrap;
color: var(--text-tertiary);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.documents-relation-choice-caret {
flex: 0 0 auto;
@@ -2228,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;
@@ -2520,7 +2825,6 @@
.documents-relation-id {
font-family: var(--font-mono);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.documents-relation-item b,
.documents-relation-item small,
@@ -2775,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;
@@ -2787,7 +3097,6 @@
.documents-revision-field span {
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.documents-revision-field-name {
display: grid;
@@ -3024,6 +3333,12 @@
}
}
@media (max-width: 700px) {
.documents-workspace-tabs {
display: none;
}
.documents-workspace-mobile {
display: grid;
}
.documents-single-media-field.documents-media-card {
grid-template-columns: 1fr;
}
@@ -3116,7 +3431,7 @@
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-areas: "check title id" "check rubric state" "date date date" "actions actions actions";
grid-template-areas: "check title id" "check rubric rubric" "state state state" "date date date" "actions actions actions";
gap: 9px 10px;
padding: 13px 12px 11px;
border-bottom: 1px solid var(--border-default);
@@ -3135,6 +3450,7 @@
box-shadow: inset 3px 0 0 var(--red-500);
}
.documents-table .documents-row td {
width: auto;
min-width: 0;
padding: 0;
border: 0;
@@ -3527,3 +3843,62 @@
grid-template-columns: 1fr;
}
}
.documents-relations-section {
margin-top: var(--space-5);
}
.documents-relations-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
}
.documents-relation-list {
display: grid;
align-content: start;
gap: var(--space-2);
min-width: 0;
}
.documents-relation-title,
.documents-relation-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.documents-relation-title {
padding-bottom: var(--space-2);
}
.documents-relation-title span {
color: var(--text-secondary);
font-size: 12px;
}
.documents-relation-row {
min-height: 48px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-primary);
text-decoration: none;
}
.documents-relation-row:hover {
background: var(--blue-50);
color: var(--blue-700);
}
.documents-relation-row > span {
display: grid;
gap: 2px;
min-width: 0;
}
.documents-relation-row b,
.documents-relation-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.documents-relation-row small {
color: var(--text-secondary);
}
@media (max-width: 820px) {
.documents-relations-grid {
grid-template-columns: 1fr;
}
}
+442 -45
View File
@@ -22,7 +22,7 @@
currentRemarkDocumentId: 0,
draftTimer: null,
draftCandidate: null,
editorMode: 'normal',
workspacePanel: 'main',
errorTargets: [],
conditionsInitialized: false,
@@ -67,8 +67,10 @@
var fieldTab = e.target.closest('[data-document-field-tab]');
if (fieldTab) { self.activateFieldGroup(fieldTab.getAttribute('data-document-field-tab')); }
var editorMode = e.target.closest('[data-document-editor-mode]');
if (editorMode) { self.setEditorMode(editorMode.getAttribute('data-document-editor-mode'), true); }
var workspaceTab = e.target.closest('[data-document-workspace-tab]');
if (workspaceTab && workspaceTab.getAttribute('aria-disabled') !== 'true') { self.setWorkspacePanel(workspaceTab.getAttribute('data-document-workspace-tab'), true); }
var workspaceOpen = e.target.closest('[data-document-workspace-open]');
if (workspaceOpen) { self.setWorkspacePanel(workspaceOpen.getAttribute('data-document-workspace-open'), true); }
if (e.target.closest('[data-document-draft-restore]')) { self.restoreLocalDraft(); }
if (e.target.closest('[data-document-draft-discard]')) { self.discardLocalDraft(); }
var errorJump = e.target.closest('[data-document-error-jump]');
@@ -94,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]')); }
@@ -201,7 +209,7 @@
this.updateAliasTemplateHint();
this.initTermInputs();
this.applyFieldConditions();
this.initEditorMode();
this.initWorkspace();
this.setDirty(false);
window.setTimeout(function () { self.checkLocalDraft(); }, 180);
window.addEventListener('beforeunload', function (e) {
@@ -211,6 +219,28 @@
});
}
document.addEventListener('change', function (e) {
if (e.target.matches('[data-document-workspace-select]')) {
self.setWorkspacePanel(e.target.value, true);
}
});
document.addEventListener('keydown', function (e) {
var current = e.target.closest('[data-document-workspace-tab]');
if (!current || ['ArrowLeft', 'ArrowRight', 'Home', 'End'].indexOf(e.key) === -1) { return; }
var tabs = Array.prototype.slice.call(document.querySelectorAll('[data-document-workspace-tab]')).filter(function (tab) {
return tab.getAttribute('aria-disabled') !== 'true';
});
if (!tabs.length) { return; }
e.preventDefault();
var index = tabs.indexOf(current);
if (e.key === 'Home') { index = 0; }
else if (e.key === 'End') { index = tabs.length - 1; }
else if (e.key === 'ArrowLeft') { index = (index - 1 + tabs.length) % tabs.length; }
else { index = (index + 1) % tabs.length; }
self.setWorkspacePanel(tabs[index].getAttribute('data-document-workspace-tab'), true);
});
document.addEventListener('submit', function (e) {
var presetForm = e.target.closest('[data-document-preset-form]');
if (presetForm) {
@@ -328,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 = '';
});
@@ -376,7 +408,7 @@
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.form) {
e.preventDefault();
self.submit(true);
self.saveActiveWorkspace();
}
});
@@ -736,6 +768,8 @@
applyFilters: function (form, push) {
if (!form) { return; }
clearTimeout(this.filterTimer);
this.filterTimer = null;
this.applyFilterUrl(this.filterUrl(form), push);
},
@@ -1352,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);
@@ -1366,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;
@@ -1399,33 +1437,76 @@
this.setSaveState(this.submitting ? 'saving' : (this.dirty ? 'dirty' : 'saved'));
},
initEditorMode: function () {
if (!this.form) { return; }
var mode = this.form.getAttribute('data-quick-edit') === '1' ? 'quick' : 'normal';
try { mode = window.localStorage.getItem(this.editorModeKey()) || mode; } catch (e) {}
this.setEditorMode(mode, false);
initWorkspace: function () {
if (!this.form || !document.querySelector('[data-document-workspace]')) { return; }
var panel = 'main';
try { panel = window.localStorage.getItem(this.workspaceKey()) || panel; } catch (e) {}
this.setWorkspacePanel(panel, false);
},
editorModeKey: function () {
workspaceKey: function () {
var actor = this.form ? this.form.getAttribute('data-actor-id') || '0' : '0';
var rubric = this.form ? this.form.getAttribute('data-rubric-id') || '0' : '0';
return 'ave.adminx.document-editor-mode:' + actor + ':' + rubric;
var workspace = document.querySelector('[data-document-workspace]');
var kind = workspace && workspace.getAttribute('data-catalog-mode') === '1' ? 'product' : 'document';
return 'ave.adminx.document-workspace:' + actor + ':' + rubric + ':' + kind;
},
setEditorMode: function (mode, persist) {
if (!this.form) { return; }
if (['quick', 'normal', 'advanced'].indexOf(mode) === -1) { mode = 'normal'; }
this.editorMode = mode;
this.form.setAttribute('data-editor-mode', mode);
document.querySelectorAll('[data-document-editor-mode]').forEach(function (button) {
var active = button.getAttribute('data-document-editor-mode') === mode;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (persist) {
try { window.localStorage.setItem(this.editorModeKey(), mode); } catch (e) {}
setWorkspacePanel: function (panel, persist) {
var workspace = document.querySelector('[data-document-workspace]');
if (!workspace) { return; }
var button = workspace.querySelector('[data-document-workspace-tab="' + panel + '"]');
if (!button || button.getAttribute('aria-disabled') === 'true') {
panel = 'main';
button = workspace.querySelector('[data-document-workspace-tab="main"]');
}
window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 40);
this.workspacePanel = panel;
workspace.setAttribute('data-active-panel', panel);
workspace.querySelectorAll('[data-document-workspace-tab]').forEach(function (tab) {
var active = tab.getAttribute('data-document-workspace-tab') === panel;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.setAttribute('tabindex', active ? '0' : '-1');
});
var select = workspace.querySelector('[data-document-workspace-select]');
if (select) { select.value = panel; }
if (persist) {
try { window.localStorage.setItem(this.workspaceKey(), panel); } catch (e) {}
}
window.setTimeout(function () {
if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); }
if (persist && button && typeof button.focus === 'function' && document.activeElement !== select) {
button.focus({ preventScroll: true });
}
}, 40);
},
workspaceForTarget: function (target) {
if (!target) { return 'main'; }
var productPanel = target.closest('[data-document-workspace-panel]');
if (productPanel) { return productPanel.getAttribute('data-document-workspace-panel') || 'main'; }
var section = target.closest('[data-document-section]');
return section ? section.getAttribute('data-document-section') || 'main' : 'main';
},
setWorkspaceForTarget: function (target) {
this.setWorkspacePanel(this.workspaceForTarget(target), true);
},
saveActiveWorkspace: function () {
var workspace = document.querySelector('[data-document-workspace]');
var panel = workspace ? workspace.getAttribute('data-active-panel') || 'main' : 'main';
if (panel === 'attributes' || panel === 'shipping' || panel === 'promotions') {
var form = workspace.querySelector('[data-document-workspace-panel="' + panel + '"] form');
if (form && typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }
return;
}
if (panel === 'variants') {
if (Adminx.Toast) { Adminx.Toast.show('В этом разделе нет несохранённых полей', 'info'); }
return;
}
this.submit(true);
},
draftKey: function () {
@@ -1556,6 +1637,11 @@
promoteCreatedDocument: function (id, stayUrl) {
id = parseInt(id, 10) || 0;
if (!id) { return; }
var workspace = document.querySelector('[data-document-workspace]');
if (workspace && workspace.getAttribute('data-catalog-mode') === '1') {
window.location.href = stayUrl;
return;
}
window.history.replaceState({ adminxDocumentEdit: true }, '', stayUrl);
document.querySelectorAll('[data-document-remarks], [data-document-aliases], [data-document-revisions], [data-document-snapshot], [data-open-drawer="documentPresetDrawer"]').forEach(function (button) {
button.disabled = false;
@@ -2079,6 +2165,7 @@
if (jumpPanel !== null) { self.activateFieldGroup(jumpPanel); }
var firstInvalid = self.form.querySelector('.is-invalid');
if (firstInvalid) {
self.setWorkspaceForTarget(firstInvalid);
window.setTimeout(function () {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
var input = firstInvalid.matches('input,select,textarea') ? firstInvalid : firstInvalid.querySelector('input,select,textarea,[contenteditable="true"]');
@@ -2118,12 +2205,7 @@
jumpToError: function (index) {
var target = this.errorTargets[parseInt(index, 10) || 0];
if (!target) { return; }
var level = target.closest('[data-editor-level]');
if (level) {
var required = level.getAttribute('data-editor-level');
if (required === 'advanced') { this.setEditorMode('advanced', true); }
else if (required === 'normal' && this.editorMode === 'quick') { this.setEditorMode('normal', true); }
}
this.setWorkspaceForTarget(target);
var panel = target.closest('[data-document-field-panel]');
if (panel) { this.activateFieldGroup(panel.getAttribute('data-document-field-panel')); }
window.setTimeout(function () {
@@ -2410,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());
@@ -2425,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 {
@@ -2555,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;
@@ -2573,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; }
@@ -3256,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>';
@@ -3396,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);
+3 -1
View File
@@ -6,7 +6,7 @@
filterTimer: null,
filterAbort: null,
filterRequest: 0,
base: function () { return window.ADMINX_BASE || '/adminx'; },
base: function () { return window.ADMINX_BASE || Adminx.base(); },
csrf: function () { var el = document.querySelector('[data-redirect-csrf]'); return el ? el.value : ''; },
json: function (response) { return response.json().then(function (data) { if (!response.ok || data.success === false) { throw new Error(data.message || 'Ошибка запроса'); } return data; }); },
esc: function (value) { var div = document.createElement('div'); div.textContent = value == null ? '' : String(value); return div.innerHTML; },
@@ -47,6 +47,8 @@
load: function (url, push) {
var self = this;
clearTimeout(this.filterTimer);
this.filterTimer = null;
var requestId = ++this.filterRequest;
var active = document.activeElement;
var focusName = active && active.closest && active.closest('[data-redirect-filter]') ? active.getAttribute('name') : '';
@@ -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>
@@ -1,47 +1,302 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_003cc12783448df3">x</phrase>
<phrase data="auto_0156bf1b8aac8565">Add more...</phrase>
<phrase data="auto_02b4ebb5dc50bca5">The address of the document will be written into the link field.</phrase>
<phrase data="auto_02fdd4b8923613d6">Up to 10 characters, optional.</phrase>
<phrase data="auto_04c0389bc0db2353">Token copied</phrase>
<phrase data="auto_080887ebc7bb5402">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-media-remove&quot; type=&quot;button&quot; data-document-media-remove data-tooltip=&quot;Delete&quot; aria-label=&quot;Delete&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_0957de938411c15b">field</phrase>
<phrase data="auto_0af969e08c220502">Clear all field lines?</phrase>
<phrase data="auto_0db49b55557f2bde">Parameter</phrase>
<phrase data="auto_0e7fbe3404b67fe3">Rebuild JSON snapshots?</phrase>
<phrase data="auto_0f9ccee053fe3b6b">New</phrase>
<phrase data="auto_0ff730d95f9c5bc3">click to select</phrase>
<phrase data="auto_122f4893d3d0d802">Failed to check alias</phrase>
<phrase data="auto_1291e1971ba28ee6">API token created</phrase>
<phrase data="auto_12bfabff3d7dc01c">Add a keyword</phrase>
<phrase data="auto_18337ef195f7d88d">&lt;div class=&quot;modal-body&quot;&gt;&lt;div class=&quot;input-wrap documents-relation-search&quot;&gt;&lt;i class=&quot;ti ti-search&quot;&gt;&lt;/i&gt;&lt;input class=&quot;input&quot; type=&quot;search&quot; placeholder=&quot;ID, title or alias&quot; data-relation-search&gt;&lt;/div&gt;&lt;div class=&quot;documents-picker-status&quot; data-relation-status&gt;Loading...&lt;/div&gt;&lt;div class=&quot;documents-relation-list&quot; data-relation-list&gt;&lt;/div&gt;&lt;/div&gt;</phrase>
<phrase data="auto_1894f01f2cfa4702">JSON snapshot</phrase>
<phrase data="auto_1a7725e697463fd8">to</phrase>
<phrase data="auto_1af72c14888c4574">Creation preset</phrase>
<phrase data="auto_1bc16af35fa47103">uh</phrase>
<phrase data="auto_1d00a5603a94c17d">Teaser ID/link</phrase>
<phrase data="auto_1d402bc8ffda29cd">Internal server error. Please refresh the page and try again.</phrase>
<phrase data="auto_1f864100c5c97f02">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-down data-tooltip=&quot;Below&quot; aria-label=&quot;Below&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_22dc89ab84f07324">&quot;&gt;&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Select file&quot; aria-label=&quot;Select file&quot;&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_230b5877294e9726">ts</phrase>
<phrase data="auto_23289c89f8c038a0">The document ID will be written in the field.</phrase>
<phrase data="auto_242f64ddfe84899b">after saving</phrase>
<phrase data="auto_25a9845975119103">Creation preset saved</phrase>
<phrase data="auto_25dc101484dbd55c">Failed to load list</phrase>
<phrase data="auto_285cc4001478d568">h</phrase>
<phrase data="auto_28c993e4935524d8">h</phrase>
<phrase data="auto_28fb6167ec0a79fe">&lt;section class=&quot;documents-revision-system&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-settings&quot;&gt;&lt;/i&gt;&lt;b&gt;Basic settings&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_292688e9915e161b">[link]&quot; data-media-key=&quot;link&quot; value=&quot;&quot; placeholder=&quot;Link or document&quot; data-document-media-url data-document-picker-type=&quot;all&quot;&gt;</phrase>
<phrase data="auto_29f3b29df0d0e54f">Restore</phrase>
<phrase data="auto_2a9790fd6fab2922">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-doc-pick data-tooltip=&quot;Select document&quot; aria-label=&quot;Select document&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2ba58b00b4c52021">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;field&quot; checked&gt;&lt;span&gt;All&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2c501dc73177148a">ID, name or alias</phrase>
<phrase data="auto_2e31dae95f35e3b8">Select file</phrase>
<phrase data="auto_2ec20105a2757791">Column 3</phrase>
<phrase data="auto_2f1256a9610f7d64">Redirect saved</phrase>
<phrase data="auto_2ff1fe42207df255">The secret is ready to be copied.</phrase>
<phrase data="auto_301361625f901825">The server returned an incorrect response.</phrase>
<phrase data="auto_30278b05221dd957">Refresh page</phrase>
<phrase data="auto_32b74a3c47908c1e">Untitled</phrase>
<phrase data="auto_33392e5848b4a8eb">JSON snapshots reassembled:</phrase>
<phrase data="auto_336d12a4f96485a3">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-up data-tooltip=&quot;Above&quot; aria-label=&quot;Above&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_359282f9ef75e654">Document ID</phrase>
<phrase data="auto_35de32df9d942d9a">Note added</phrase>
<phrase data="auto_3697bef0b3fc93f5">new</phrase>
<phrase data="auto_3812a04b189ec1fd">new</phrase>
<phrase data="auto_39704f311a86cd23">Draft</phrase>
<phrase data="auto_3de49828e86a4016">Title</phrase>
<phrase data="auto_3e096d0063299173">Revision deleted</phrase>
<phrase data="auto_3e25fbc59741a10a">Document title</phrase>
<phrase data="auto_3e670a1ec591883c">Files uploaded</phrase>
<phrase data="auto_3eb77d3c13c51f92">Select revision</phrase>
<phrase data="auto_3f039532d65bb5e3">Rebuild JSON</phrase>
<phrase data="auto_4044fbc87708b9ad">Clear daily statistics?</phrase>
<phrase data="auto_40785832c27c3a51">Column 1</phrase>
<phrase data="auto_42ba924b3732dc07">fields</phrase>
<phrase data="auto_433e4b40a403cdb0">th</phrase>
<phrase data="auto_447d642f14fb2c8c">There are no matching files in the folder</phrase>
<phrase data="auto_46551a5e6e43e854">Statistics cleared</phrase>
<phrase data="auto_48be85b47e187f3c">not created yet</phrase>
<phrase data="auto_4a05d8619dedc462">Copy</phrase>
<phrase data="auto_4b4c88ed13b23b15">Redirect removed</phrase>
<phrase data="auto_4d60d98f499d1580">Audits</phrase>
<phrase data="auto_4f566fb5e3df4276">I</phrase>
<phrase data="auto_516cbe2af24391af">l</phrase>
<phrase data="auto_51b4eac98af84251">a</phrase>
<phrase data="auto_51f784cde1916288">b</phrase>
<phrase data="auto_522e27d7402089bc">Document saved</phrase>
<phrase data="auto_53e908b396e3a6b3">&lt;span class=&quot;badge badge-gray&quot;&gt;not created&lt;/span&gt;</phrase>
<phrase data="auto_54a8375d14dfdab5">Failed to read folder</phrase>
<phrase data="auto_56ac0cff0f4a358f">Note deleted</phrase>
<phrase data="auto_576895a59897535b">File not selected</phrase>
<phrase data="auto_577021b5ac1fd915">f</phrase>
<phrase data="auto_57afad1302f9645e">o</phrase>
<phrase data="auto_588c8feeb973dcb1">&lt;div&gt;&lt;dt&gt;File&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_59ddef8a55503c83">kg</phrase>
<phrase data="auto_5a3b4bcf55400711">r</phrase>
<phrase data="auto_5bd0d9fe0580526f">. The window must be left open until completion.</phrase>
<phrase data="auto_5c019fa5d03ca5cf">in the section</phrase>
<phrase data="auto_5e6c96299a2f11e9">s</phrase>
<phrase data="auto_5e6d6b07b27b7a7c">All view_count lines will be removed. General document counters will remain unchanged.</phrase>
<phrase data="auto_5f9eaa37c4e2ccff">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Select file&quot; aria-label=&quot;Select file&quot;&gt;&lt;i class=&quot;ti ti-paperclip&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_600363a73de5f3a5">Rebuild</phrase>
<phrase data="auto_60512114c18fd5ed">&lt;span class=&quot;badge badge-amber&quot;&gt;needs reassembly&lt;/span&gt;</phrase>
<phrase data="auto_619742ac4a834c45">doc.</phrase>
<phrase data="auto_63b1fa390e91f75c">0 B</phrase>
<phrase data="auto_65b7f1673713a00c">d</phrase>
<phrase data="auto_66d207b9ebe9e4e3">&lt;div class=&quot;documents-term-status is-error&quot;&gt;&lt;i class=&quot;ti ti-alert-circle&quot;&gt;&lt;/i&gt;&lt;span&gt;Failed to load options&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6702cc12ca25aff1">&lt;div class=&quot;empty-state&quot;&gt;There are no revisions yet. The first snapshot will appear after saving the document.&lt;/div&gt;</phrase>
<phrase data="auto_691245657a52bdce">&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Restore</phrase>
<phrase data="auto_6972375800d3e590">Clear the field?</phrase>
<phrase data="auto_698ace159126e252">Column 2</phrase>
<phrase data="auto_6b595786e068b5bc">Alias can be left empty</phrase>
<phrase data="auto_6baaabf049b42cca">e</phrase>
<phrase data="auto_6bee957a1f3ef55d">Length</phrase>
<phrase data="auto_6c190c23240564f5">f</phrase>
<phrase data="auto_6e408d3c9c3156bf">Delete &quot;</phrase>
<phrase data="auto_6e52ded051aa402d">Withdrawn</phrase>
<phrase data="auto_6f658622a374b8d6">&lt;/p&gt;&lt;/div&gt;&lt;button class=&quot;modal-close&quot; type=&quot;button&quot; data-relation-close aria-label=&quot;Close&quot;&gt;&lt;i class=&quot;ti ti-x&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6fe63eee472a5a93">&lt;span class=&quot;badge badge-green&quot;&gt;relevant&lt;/span&gt;</phrase>
<phrase data="auto_707a724dce1fa0d4">URL copied</phrase>
<phrase data="auto_72c618d46424c93a">There are no saved values yet</phrase>
<phrase data="auto_736a75710565c2f2">[description]&quot; data-media-key=&quot;description&quot; rows=&quot;2&quot; placeholder=&quot;Description&quot;&gt;&lt;/textarea&gt;</phrase>
<phrase data="auto_75285ce97dc1dc97">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-down data-tooltip=&quot;Below&quot; aria-label=&quot;Below&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_781e545c1063d5db">The integration will immediately lose access. It will be impossible to return this token.</phrase>
<phrase data="auto_7845e83aad6bffff">Errors found:</phrase>
<phrase data="auto_7a4f40b5797415a7">Category template: /</phrase>
<phrase data="auto_7a9b21737266b2d1">, errors:</phrase>
<phrase data="auto_7bd54e89985213b9">Template</phrase>
<phrase data="auto_7c3bb087be98e4e9">Failed to load statistics</phrase>
<phrase data="auto_7dcc70f455f9f122">API token revoked</phrase>
<phrase data="auto_7f09659cde925652">Find or add a tag</phrase>
<phrase data="auto_7f7328fc7d7316b8">n</phrase>
<phrase data="auto_80a2c74df6ffb08d">Open the desired folder and confirm your selection. All suitable files from it will be added to the field.</phrase>
<phrase data="auto_80c97478d2179d1c">&lt;div&gt;&lt;dt&gt;State&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_83c8e08533872aff">&lt;div class=&quot;empty-state&quot;&gt;Loading...&lt;/div&gt;</phrase>
<phrase data="auto_84b96b359e9c61d3">The contents of the snapshot will appear after selecting a revision.</phrase>
<phrase data="auto_86ea33aef5e95d4f">Delete</phrase>
<phrase data="auto_8922542f90c16aee">Copied</phrase>
<phrase data="auto_8bf2ee2944fdd73f">optional</phrase>
<phrase data="auto_8c34e8bce7d519b3">The description will appear after filling out the meta description.</phrase>
<phrase data="auto_8d333d8b91196c4b">Documents</phrase>
<phrase data="auto_8d5d38d878604c11">The document has been restored</phrase>
<phrase data="auto_8e8ca8428150664e">g</phrase>
<phrase data="auto_8ff9429d66687894">No matches</phrase>
<phrase data="auto_919be7c1e27ba64c">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-up data-tooltip=&quot;Above&quot; aria-label=&quot;Above&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_9541be7605dbe971">Permissions for one external integration.</phrase>
<phrase data="auto_96664ee8380d446c">The document has changed</phrase>
<phrase data="auto_9729bca62f6c558e">Request Error</phrase>
<phrase data="auto_97c557fb133f1f95">fields</phrase>
<phrase data="auto_98b2073ed1815f28">Clear</phrase>
<phrase data="auto_9b16ca539e602189">Revoke</phrase>
<phrase data="auto_9bf756bd3124a21d">Changes saved</phrase>
<phrase data="auto_9c4c0897f8ec6cf2">[title]&quot; data-media-key=&quot;title&quot; value=&quot;&quot; placeholder=&quot;Image title&quot;&gt;</phrase>
<phrase data="auto_9c7449bfd16b63a0">Remove</phrase>
<phrase data="auto_9caf4a95f812be6a">n</phrase>
<phrase data="auto_9d1e4bc060f5da40">Added from folder:</phrase>
<phrase data="auto_9d809f884b63cdec">Edit</phrase>
<phrase data="auto_9da304d709611b05">Failed to load revisions</phrase>
<phrase data="auto_9f0b990967c8a2ad">Meaning</phrase>
<phrase data="auto_9f3b3c3b78133faa">Select document</phrase>
<phrase data="auto_a15870f87e67e329">&lt;div&gt;&lt;dt&gt;Formed by&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a18928c7baf57b4e">Fields only</phrase>
<phrase data="auto_a2e9f606da125a72">b</phrase>
<phrase data="auto_a37cb7b62a6f1b65">The category does not have a path template: the document alias is used from the root of the site.</phrase>
<phrase data="auto_a45e66bc42d77b08">Failed to generate alias</phrase>
<phrase data="auto_a59e2c2429fe7a88">and</phrase>
<phrase data="auto_a5ecb551ebd318c2">Height,</phrase>
<phrase data="auto_a63e1ce2e55b8d0a">No image selected</phrase>
<phrase data="auto_a690b9dead4d8d8d">&lt;div&gt;&lt;dt&gt;Size&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a7774650638b586d">JSON snapshot rebuilt</phrase>
<phrase data="auto_a78e4bb6eb8c9ced">Document revisions</phrase>
<phrase data="auto_a8504d513adfb6f5">Heading</phrase>
<phrase data="auto_a90ed3357338f605">Loading...</phrase>
<phrase data="auto_ab30701c85ebee86">Remove document</phrase>
<phrase data="auto_ac3008315dc50b25">Add from this folder</phrase>
<phrase data="auto_ae008e22ec3a8627">All lines in this field will be removed from the document after saving.</phrase>
<phrase data="auto_af962c31c15d28d8">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-drag-handle&quot; type=&quot;button&quot; data-doc-drag draggable=&quot;true&quot; data-tooltip=&quot;Drag&quot; aria-label=&quot;Drag&quot;&gt;&lt;i class=&quot;ti ti-grip-vertical&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_b0a419827426861b">Preset deleted</phrase>
<phrase data="auto_b0d827d33fc8eb3f">Pictures</phrase>
<phrase data="auto_b1c30d9335b4ffb7">/ + document alias. The date is taken from the publication.</phrase>
<phrase data="auto_b279170b217c1e48">&lt;div&gt;&lt;dt&gt;Fields&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_b2ac54889c1e667e">Batch rebuild stopped</phrase>
<phrase data="auto_b36631e2d2874351">&lt;article class=&quot;documents-revision-field documents-revision-system-field&quot;&gt;&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Restore</phrase>
<phrase data="auto_b383b6f9be5e7ca2">Failed to get snapshot status</phrase>
<phrase data="auto_b40bbca36d2d172e">with</phrase>
<phrase data="auto_b5251271237d718b">These files are already in the field</phrase>
<phrase data="auto_b92a4704544f84aa">The pictures will be sequentially reassembled to</phrase>
<phrase data="auto_b9917824e609b5b3">pictures</phrase>
<phrase data="auto_baa96cc9f67a7eb8">yu</phrase>
<phrase data="auto_be1aff65661f854c">[name]&quot; data-media-key=&quot;name&quot; value=&quot;&quot; placeholder=&quot;File name&quot;&gt;</phrase>
<phrase data="auto_be4a59e132257571">Revoke an API token?</phrase>
<phrase data="auto_be7ec45710267c5c">&lt;section class=&quot;documents-revision-content&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-forms&quot;&gt;&lt;/i&gt;&lt;b&gt;Category fields&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_bea15c7bbd9e167c">e</phrase>
<phrase data="auto_beed168817322eeb">from</phrase>
<phrase data="auto_bf6379458cca6658">m</phrase>
<phrase data="auto_c13875d6c76e5c2e">w</phrase>
<phrase data="auto_c2b70c3bb20678a7">Failed to apply filters</phrase>
<phrase data="auto_c52083faa73d3829">Field</phrase>
<phrase data="auto_c5eb26618a7ede98">Read only</phrase>
<phrase data="auto_c7fca87d8dd9b0e6">Click to find document</phrase>
<phrase data="auto_c9d8ea6476aa730d">Add &quot;</phrase>
<phrase data="auto_cccd51a8c6aa88c1">&lt;div class=&quot;documents-term-status&quot;&gt;&lt;i class=&quot;ti ti-loader-2&quot;&gt;&lt;/i&gt;&lt;span&gt;Looking for matches...&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_ccfe048a5551ed0f">preset</phrase>
<phrase data="auto_cdd3b42f2a48df2f">Delete preset</phrase>
<phrase data="auto_ceebfda5155965db">Failed to generate short alias</phrase>
<phrase data="auto_cf0fc49836b59906">can be left blank</phrase>
<phrase data="auto_cf412de9896e972b">Select at least one resolution</phrase>
<phrase data="auto_d0888e0521387c25">Width,</phrase>
<phrase data="auto_d164037dc0f1bd06">Delete revision</phrase>
<phrase data="auto_d2264bee0979ddf6">&lt;div&gt;&lt;dt&gt;Status&lt;/dt&gt;&lt;dd&gt;Loading...&lt;/dd&gt;&lt;/div&gt;</phrase>
<phrase data="auto_d6951b2c8c477f98">All elements of this field will be removed from the document after saving.</phrase>
<phrase data="auto_d6b08d92a7694cd4">Weight,</phrase>
<phrase data="auto_d77df87cd6098c73">History of field values</phrase>
<phrase data="auto_d7907f4d435df378">Additionally</phrase>
<phrase data="auto_d944cde8b15c4d8b">at</phrase>
<phrase data="auto_d9c83b31f5cb2376">Failed to check short alias</phrase>
<phrase data="auto_da97cf5fe3fc4c42">Checking...</phrase>
<phrase data="auto_db4d819570e77e56">draft</phrase>
<phrase data="auto_dcedec8acc4af7ca">Assignment document</phrase>
<phrase data="auto_dd0ec9a1c7140562">Add files from a folder</phrase>
<phrase data="auto_defc150c8003aa92">Revisions deleted</phrase>
<phrase data="auto_df5763ef792cf2f4">cm</phrase>
<phrase data="auto_e01aed78c667a5aa">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-value-remove&quot; type=&quot;button&quot; data-document-value-remove data-tooltip=&quot;Delete&quot; aria-label=&quot;Delete&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
<phrase data="auto_e576260a6a03f359">t</phrase>
<phrase data="auto_e76f1ce35ab97dfb">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;document&quot; checked&gt;&lt;span&gt;All&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_e83e7fbf2312ab9e">The document has already been changed. Refresh the page.</phrase>
<phrase data="auto_e94209605c9c0a4e">&lt;button class=&quot;documents-media-thumb&quot; type=&quot;button&quot; data-document-media-pick aria-label=&quot;Select file&quot;&gt;&lt;span&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_ec6be72560e6078a">The story is still empty</phrase>
<phrase data="auto_eeb60e75b96656e7">Incorrect answer</phrase>
<phrase data="auto_f0b532724126bbe8">Clear all field elements?</phrase>
<phrase data="auto_f26e5c0c72082089">Find or add a keyword</phrase>
<phrase data="auto_f3d123e46297531d">in</phrase>
<phrase data="auto_f42c85cd57ba4aa0">Stay</phrase>
<phrase data="auto_f6069600bcb07eea">&lt;div class=&quot;modal-footer&quot;&gt;&lt;div class=&quot;mf-left documents-picker-count&quot; data-relation-count&gt;&lt;/div&gt;&lt;button class=&quot;btn btn-ghost&quot; type=&quot;button&quot; data-relation-close&gt;Close&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_f61de80bf0ca15a4">No document selected</phrase>
<phrase data="auto_f7286439b109e428">New redirect</phrase>
<phrase data="auto_f7806fce80e51b40">Old URL</phrase>
<phrase data="auto_f914c65278294e4a">&lt;div class=&quot;modal-header&quot;&gt;&lt;span class=&quot;dialog-icon info&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/span&gt;&lt;div style=&quot;flex:1&quot;&gt;&lt;h3&gt;Select document&lt;/h3&gt;&lt;p class=&quot;text-secondary&quot; style=&quot;margin-top:4px&quot;&gt;</phrase>
<phrase data="auto_fa9f0524f3a0531e">Add a tag</phrase>
<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>
<phrase data="runtime_11cef8141437f8a6">ъ</phrase>
<phrase data="runtime_15f2ccabeb425c29">th</phrase>
<phrase data="runtime_1cc10a3947f7c245">d</phrase>
<phrase data="runtime_1d51ebe0fc45e38e">Fields only</phrase>
<phrase data="runtime_259f56cb715ba3a3">e</phrase>
<phrase data="runtime_3051067c6324c63f">h</phrase>
<phrase data="runtime_30fbc377ae9122ed">e</phrase>
<phrase data="runtime_3164696756227147">Note added</phrase>
<phrase data="runtime_34c6fbb62da8ce5c">n</phrase>
<phrase data="runtime_37d43a57a463add5">yu</phrase>
<phrase data="runtime_40831c5d98a57dfc">h</phrase>
<phrase data="runtime_446cdc8e3c0d7efc">s</phrase>
<phrase data="runtime_46b0d3855548054a">JSON snapshot rebuilt</phrase>
<phrase data="runtime_4f60bd4e24e7dc7f">w</phrase>
<phrase data="runtime_50338c005c41fb4e">o</phrase>
<phrase data="runtime_5225a95b111d47bf">to</phrase>
<phrase data="runtime_53b10ee4405f56d6">not created yet</phrase>
<phrase data="runtime_557f4cd2c6d3c150">cm</phrase>
<phrase data="runtime_61095f9d62dfe291">The document has been restored</phrase>
<phrase data="runtime_6228f1454b196ffd">API token revoked</phrase>
<phrase data="runtime_65fd24f1cb37fec4">Draft</phrase>
<phrase data="runtime_72a81659de042887">Revisions deleted</phrase>
<phrase data="runtime_734dbc2b54ab6c95">r</phrase>
<phrase data="runtime_754309a41e0df98a">f</phrase>
<phrase data="runtime_7592b7ec4eba966a">ts</phrase>
<phrase data="runtime_7b0ff8a2e5fdd66d">m</phrase>
<phrase data="runtime_823c4eb3e895adc9">a</phrase>
<phrase data="runtime_85011fce4419ee63">Field #</phrase>
<phrase data="runtime_894f97c52af9e5ac">Note deleted</phrase>
<phrase data="runtime_9aa032336a8e122c">b</phrase>
<phrase data="runtime_9b21f5cf014c9efb">I</phrase>
<phrase data="runtime_9b6276c9a4ac5507">Document saved</phrase>
<phrase data="runtime_a1f5d39a12e8f6be">with</phrase>
<phrase data="runtime_ab1f4be7e5d6fd8b">g</phrase>
<phrase data="runtime_ae51ade91d2b85b7">Revision deleted</phrase>
<phrase data="runtime_b8012cb642c887a0">in</phrase>
<phrase data="runtime_b95d78565f154f82">b</phrase>
<phrase data="runtime_bb69a96efca3303e">x</phrase>
<phrase data="runtime_bbb369f210d2969f">sch</phrase>
<phrase data="runtime_beee80c256b458da">at</phrase>
<phrase data="runtime_bf0d18ef68a8a34e">can be left blank</phrase>
<phrase data="runtime_c08fb476eca49399">kg</phrase>
<phrase data="runtime_c5412ba2cc2fc45a">Creation preset saved</phrase>
<phrase data="runtime_c78364c5d0f27706">B</phrase>
<phrase data="runtime_cdd02bd8b5684812">Redirect removed</phrase>
<phrase data="runtime_d92fff560eb5495e">l</phrase>
<phrase data="runtime_dd48804aa38b03e1">Preset deleted</phrase>
<phrase data="runtime_dd591284d827e17f">f</phrase>
<phrase data="runtime_e1c88af54b9fced2">Document #</phrase>
<phrase data="runtime_e35c8bb151d3530d">Redirect saved</phrase>
<phrase data="runtime_e93c3d6287d39cc3">API token created</phrase>
<phrase data="runtime_ee71f8405f87d786">uh</phrase>
<phrase data="runtime_eead37caf818d0fa">and</phrase>
<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,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_catalog_empty">No sections selected.</phrase>
<phrase data="documents_catalog_add">Add section</phrase>
<phrase data="documents_catalog_search">Find section</phrase>
<phrase data="documents_catalog_remove">Remove section</phrase>
<phrase data="documents_catalog_document_prefix">document #</phrase>
<phrase data="documents_catalog_hidden">hidden</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>
@@ -1,47 +1,302 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_003cc12783448df3">х</phrase>
<phrase data="auto_0156bf1b8aac8565">Добавить ещё…</phrase>
<phrase data="auto_02b4ebb5dc50bca5">В поле-ссылку будет записан адрес документа.</phrase>
<phrase data="auto_02fdd4b8923613d6">До 10 символов, необязательно.</phrase>
<phrase data="auto_04c0389bc0db2353">Токен скопирован</phrase>
<phrase data="auto_080887ebc7bb5402">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-media-remove&quot; type=&quot;button&quot; data-document-media-remove data-tooltip=&quot;Удалить&quot; aria-label=&quot;Удалить&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_0957de938411c15b">поле</phrase>
<phrase data="auto_0af969e08c220502">Очистить все строки поля?</phrase>
<phrase data="auto_0db49b55557f2bde">Параметр</phrase>
<phrase data="auto_0e7fbe3404b67fe3">Пересобрать JSON-снимки?</phrase>
<phrase data="auto_0f9ccee053fe3b6b">Новый</phrase>
<phrase data="auto_0ff730d95f9c5bc3">нажмите, чтобы выбрать</phrase>
<phrase data="auto_122f4893d3d0d802">Не удалось проверить alias</phrase>
<phrase data="auto_1291e1971ba28ee6">API-токен создан</phrase>
<phrase data="auto_12bfabff3d7dc01c">Добавить ключевое слово</phrase>
<phrase data="auto_18337ef195f7d88d">&lt;div class=&quot;modal-body&quot;&gt;&lt;div class=&quot;input-wrap documents-relation-search&quot;&gt;&lt;i class=&quot;ti ti-search&quot;&gt;&lt;/i&gt;&lt;input class=&quot;input&quot; type=&quot;search&quot; placeholder=&quot;ID, название или alias&quot; data-relation-search&gt;&lt;/div&gt;&lt;div class=&quot;documents-picker-status&quot; data-relation-status&gt;Загрузка...&lt;/div&gt;&lt;div class=&quot;documents-relation-list&quot; data-relation-list&gt;&lt;/div&gt;&lt;/div&gt;</phrase>
<phrase data="auto_1894f01f2cfa4702">JSON-снимок</phrase>
<phrase data="auto_1a7725e697463fd8">к</phrase>
<phrase data="auto_1af72c14888c4574">Пресет создания</phrase>
<phrase data="auto_1bc16af35fa47103">э</phrase>
<phrase data="auto_1d00a5603a94c17d">ID тизера / ссылка</phrase>
<phrase data="auto_1d402bc8ffda29cd">Внутренняя ошибка сервера. Обновите страницу и повторите попытку.</phrase>
<phrase data="auto_1f864100c5c97f02">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-down data-tooltip=&quot;Ниже&quot; aria-label=&quot;Ниже&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_22dc89ab84f07324">&quot;&gt;&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Выбрать файл&quot; aria-label=&quot;Выбрать файл&quot;&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_230b5877294e9726">ц</phrase>
<phrase data="auto_23289c89f8c038a0">В поле будет записан ID документа.</phrase>
<phrase data="auto_242f64ddfe84899b">после сохранения</phrase>
<phrase data="auto_25a9845975119103">Пресет создания сохранён</phrase>
<phrase data="auto_25dc101484dbd55c">Не удалось загрузить список</phrase>
<phrase data="auto_285cc4001478d568">ч</phrase>
<phrase data="auto_28c993e4935524d8">з</phrase>
<phrase data="auto_28fb6167ec0a79fe">&lt;section class=&quot;documents-revision-system&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-settings&quot;&gt;&lt;/i&gt;&lt;b&gt;Основные настройки&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_292688e9915e161b">[link]&quot; data-media-key=&quot;link&quot; value=&quot;&quot; placeholder=&quot;Ссылка или документ&quot; data-document-media-url data-document-picker-type=&quot;all&quot;&gt;</phrase>
<phrase data="auto_29f3b29df0d0e54f">Восстановить</phrase>
<phrase data="auto_2a9790fd6fab2922">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-doc-pick data-tooltip=&quot;Выбрать документ&quot; aria-label=&quot;Выбрать документ&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2ba58b00b4c52021">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;field&quot; checked&gt;&lt;span&gt;Все&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2c501dc73177148a">ID, название или alias</phrase>
<phrase data="auto_2e31dae95f35e3b8">Выбрать файл</phrase>
<phrase data="auto_2ec20105a2757791">Колонка 3</phrase>
<phrase data="auto_2f1256a9610f7d64">Редирект сохранён</phrase>
<phrase data="auto_2ff1fe42207df255">Секрет готов к копированию.</phrase>
<phrase data="auto_301361625f901825">Сервер вернул некорректный ответ.</phrase>
<phrase data="auto_30278b05221dd957">Обновить страницу</phrase>
<phrase data="auto_32b74a3c47908c1e">Без названия</phrase>
<phrase data="auto_33392e5848b4a8eb">JSON-снимки пересобраны:</phrase>
<phrase data="auto_336d12a4f96485a3">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-up data-tooltip=&quot;Выше&quot; aria-label=&quot;Выше&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_359282f9ef75e654">ID документа</phrase>
<phrase data="auto_35de32df9d942d9a">Заметка добавлена</phrase>
<phrase data="auto_3697bef0b3fc93f5">новый</phrase>
<phrase data="auto_3812a04b189ec1fd">новое</phrase>
<phrase data="auto_39704f311a86cd23">Черновик</phrase>
<phrase data="auto_3de49828e86a4016">Название</phrase>
<phrase data="auto_3e096d0063299173">Ревизия удалена</phrase>
<phrase data="auto_3e25fbc59741a10a">Заголовок документа</phrase>
<phrase data="auto_3e670a1ec591883c">Файлы загружены</phrase>
<phrase data="auto_3eb77d3c13c51f92">Выберите ревизию</phrase>
<phrase data="auto_3f039532d65bb5e3">Пересобрать JSON</phrase>
<phrase data="auto_4044fbc87708b9ad">Очистить подневную статистику?</phrase>
<phrase data="auto_40785832c27c3a51">Колонка 1</phrase>
<phrase data="auto_42ba924b3732dc07">поля</phrase>
<phrase data="auto_433e4b40a403cdb0">й</phrase>
<phrase data="auto_447d642f14fb2c8c">В папке нет подходящих файлов</phrase>
<phrase data="auto_46551a5e6e43e854">Статистика очищена</phrase>
<phrase data="auto_48be85b47e187f3c">ещё не создан</phrase>
<phrase data="auto_4a05d8619dedc462">Копировать</phrase>
<phrase data="auto_4b4c88ed13b23b15">Редирект удалён</phrase>
<phrase data="auto_4d60d98f499d1580">Ревизии</phrase>
<phrase data="auto_4f566fb5e3df4276">я</phrase>
<phrase data="auto_516cbe2af24391af">л</phrase>
<phrase data="auto_51b4eac98af84251">а</phrase>
<phrase data="auto_51f784cde1916288">ь</phrase>
<phrase data="auto_522e27d7402089bc">Документ сохранён</phrase>
<phrase data="auto_53e908b396e3a6b3">&lt;span class=&quot;badge badge-gray&quot;&gt;не создан&lt;/span&gt;</phrase>
<phrase data="auto_54a8375d14dfdab5">Не удалось прочитать папку</phrase>
<phrase data="auto_56ac0cff0f4a358f">Заметка удалена</phrase>
<phrase data="auto_576895a59897535b">Файл не выбран</phrase>
<phrase data="auto_577021b5ac1fd915">ф</phrase>
<phrase data="auto_57afad1302f9645e">о</phrase>
<phrase data="auto_588c8feeb973dcb1">&lt;div&gt;&lt;dt&gt;Файл&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_59ddef8a55503c83">кг</phrase>
<phrase data="auto_5a3b4bcf55400711">р</phrase>
<phrase data="auto_5bd0d9fe0580526f">. Окно нужно оставить открытым до завершения.</phrase>
<phrase data="auto_5c019fa5d03ca5cf">в рубрике</phrase>
<phrase data="auto_5e6c96299a2f11e9">ы</phrase>
<phrase data="auto_5e6d6b07b27b7a7c">Все строки view_count будут удалены. Общие счётчики документов останутся без изменений.</phrase>
<phrase data="auto_5f9eaa37c4e2ccff">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Выбрать файл&quot; aria-label=&quot;Выбрать файл&quot;&gt;&lt;i class=&quot;ti ti-paperclip&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_600363a73de5f3a5">Пересобрать</phrase>
<phrase data="auto_60512114c18fd5ed">&lt;span class=&quot;badge badge-amber&quot;&gt;нужна пересборка&lt;/span&gt;</phrase>
<phrase data="auto_619742ac4a834c45">док.</phrase>
<phrase data="auto_63b1fa390e91f75c">0 Б</phrase>
<phrase data="auto_65b7f1673713a00c">д</phrase>
<phrase data="auto_66d207b9ebe9e4e3">&lt;div class=&quot;documents-term-status is-error&quot;&gt;&lt;i class=&quot;ti ti-alert-circle&quot;&gt;&lt;/i&gt;&lt;span&gt;Не удалось загрузить варианты&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6702cc12ca25aff1">&lt;div class=&quot;empty-state&quot;&gt;Ревизий пока нет. Первый снимок появится после сохранения документа.&lt;/div&gt;</phrase>
<phrase data="auto_691245657a52bdce">&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Восстановить</phrase>
<phrase data="auto_6972375800d3e590">Очистить поле?</phrase>
<phrase data="auto_698ace159126e252">Колонка 2</phrase>
<phrase data="auto_6b595786e068b5bc">Alias можно оставить пустым</phrase>
<phrase data="auto_6baaabf049b42cca">ё</phrase>
<phrase data="auto_6bee957a1f3ef55d">Длина,</phrase>
<phrase data="auto_6c190c23240564f5">ж</phrase>
<phrase data="auto_6e408d3c9c3156bf">Удалить «</phrase>
<phrase data="auto_6e52ded051aa402d">Отозван</phrase>
<phrase data="auto_6f658622a374b8d6">&lt;/p&gt;&lt;/div&gt;&lt;button class=&quot;modal-close&quot; type=&quot;button&quot; data-relation-close aria-label=&quot;Закрыть&quot;&gt;&lt;i class=&quot;ti ti-x&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6fe63eee472a5a93">&lt;span class=&quot;badge badge-green&quot;&gt;актуален&lt;/span&gt;</phrase>
<phrase data="auto_707a724dce1fa0d4">URL скопирован</phrase>
<phrase data="auto_72c618d46424c93a">Сохранённых значений пока нет</phrase>
<phrase data="auto_736a75710565c2f2">[description]&quot; data-media-key=&quot;description&quot; rows=&quot;2&quot; placeholder=&quot;Описание&quot;&gt;&lt;/textarea&gt;</phrase>
<phrase data="auto_75285ce97dc1dc97">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-down data-tooltip=&quot;Ниже&quot; aria-label=&quot;Ниже&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_781e545c1063d5db">Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно.</phrase>
<phrase data="auto_7845e83aad6bffff">Найдено ошибок:</phrase>
<phrase data="auto_7a4f40b5797415a7">Шаблон рубрики: /</phrase>
<phrase data="auto_7a9b21737266b2d1">, ошибок:</phrase>
<phrase data="auto_7bd54e89985213b9">Шаблон</phrase>
<phrase data="auto_7c3bb087be98e4e9">Не удалось загрузить статистику</phrase>
<phrase data="auto_7dcc70f455f9f122">API-токен отозван</phrase>
<phrase data="auto_7f09659cde925652">Найти или добавить тег</phrase>
<phrase data="auto_7f7328fc7d7316b8">н</phrase>
<phrase data="auto_80a2c74df6ffb08d">Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё.</phrase>
<phrase data="auto_80c97478d2179d1c">&lt;div&gt;&lt;dt&gt;Состояние&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_83c8e08533872aff">&lt;div class=&quot;empty-state&quot;&gt;Загрузка...&lt;/div&gt;</phrase>
<phrase data="auto_84b96b359e9c61d3">Содержимое снимка появится после выбора ревизии.</phrase>
<phrase data="auto_86ea33aef5e95d4f">Удалить</phrase>
<phrase data="auto_8922542f90c16aee">Скопировано</phrase>
<phrase data="auto_8bf2ee2944fdd73f">необязательно</phrase>
<phrase data="auto_8c34e8bce7d519b3">Описание появится после заполнения meta description.</phrase>
<phrase data="auto_8d333d8b91196c4b">Документов</phrase>
<phrase data="auto_8d5d38d878604c11">Документ восстановлен</phrase>
<phrase data="auto_8e8ca8428150664e">г</phrase>
<phrase data="auto_8ff9429d66687894">Совпадений нет</phrase>
<phrase data="auto_919be7c1e27ba64c">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-up data-tooltip=&quot;Выше&quot; aria-label=&quot;Выше&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_9541be7605dbe971">Права доступа для одной внешней интеграции.</phrase>
<phrase data="auto_96664ee8380d446c">Документ изменился</phrase>
<phrase data="auto_9729bca62f6c558e">Ошибка запроса</phrase>
<phrase data="auto_97c557fb133f1f95">полей</phrase>
<phrase data="auto_98b2073ed1815f28">Очистить</phrase>
<phrase data="auto_9b16ca539e602189">Отозвать</phrase>
<phrase data="auto_9bf756bd3124a21d">Изменения сохранены</phrase>
<phrase data="auto_9c4c0897f8ec6cf2">[title]&quot; data-media-key=&quot;title&quot; value=&quot;&quot; placeholder=&quot;Заголовок изображения&quot;&gt;</phrase>
<phrase data="auto_9c7449bfd16b63a0">Убрать</phrase>
<phrase data="auto_9caf4a95f812be6a">п</phrase>
<phrase data="auto_9d1e4bc060f5da40">Добавлено из папки:</phrase>
<phrase data="auto_9d809f884b63cdec">Изменить</phrase>
<phrase data="auto_9da304d709611b05">Не удалось загрузить ревизии</phrase>
<phrase data="auto_9f0b990967c8a2ad">Значение</phrase>
<phrase data="auto_9f3b3c3b78133faa">Выбрать документ</phrase>
<phrase data="auto_a15870f87e67e329">&lt;div&gt;&lt;dt&gt;Сформирован&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a18928c7baf57b4e">Только поля</phrase>
<phrase data="auto_a2e9f606da125a72">б</phrase>
<phrase data="auto_a37cb7b62a6f1b65">У рубрики нет шаблона пути: alias документа используется от корня сайта.</phrase>
<phrase data="auto_a45e66bc42d77b08">Не удалось сгенерировать alias</phrase>
<phrase data="auto_a59e2c2429fe7a88">и</phrase>
<phrase data="auto_a5ecb551ebd318c2">Высота,</phrase>
<phrase data="auto_a63e1ce2e55b8d0a">Изображение не выбрано</phrase>
<phrase data="auto_a690b9dead4d8d8d">&lt;div&gt;&lt;dt&gt;Размер&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a7774650638b586d">JSON-снимок пересобран</phrase>
<phrase data="auto_a78e4bb6eb8c9ced">Ревизии документа</phrase>
<phrase data="auto_a8504d513adfb6f5">Заголовок</phrase>
<phrase data="auto_a90ed3357338f605">Загрузка...</phrase>
<phrase data="auto_ab30701c85ebee86">Убрать документ</phrase>
<phrase data="auto_ac3008315dc50b25">Добавить из этой папки</phrase>
<phrase data="auto_ae008e22ec3a8627">Все строки этого поля будут удалены из документа после сохранения.</phrase>
<phrase data="auto_af962c31c15d28d8">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-drag-handle&quot; type=&quot;button&quot; data-doc-drag draggable=&quot;true&quot; data-tooltip=&quot;Перетащить&quot; aria-label=&quot;Перетащить&quot;&gt;&lt;i class=&quot;ti ti-grip-vertical&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_b0a419827426861b">Пресет удалён</phrase>
<phrase data="auto_b0d827d33fc8eb3f">Снимки</phrase>
<phrase data="auto_b1c30d9335b4ffb7">/ + alias документа. Дата берётся из публикации.</phrase>
<phrase data="auto_b279170b217c1e48">&lt;div&gt;&lt;dt&gt;Поля&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_b2ac54889c1e667e">Пакетная пересборка остановлена</phrase>
<phrase data="auto_b36631e2d2874351">&lt;article class=&quot;documents-revision-field documents-revision-system-field&quot;&gt;&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Восстановить</phrase>
<phrase data="auto_b383b6f9be5e7ca2">Не удалось получить статус снимка</phrase>
<phrase data="auto_b40bbca36d2d172e">с</phrase>
<phrase data="auto_b5251271237d718b">Эти файлы уже есть в поле</phrase>
<phrase data="auto_b92a4704544f84aa">Снимки будут последовательно пересобраны для</phrase>
<phrase data="auto_b9917824e609b5b3">снимков</phrase>
<phrase data="auto_baa96cc9f67a7eb8">ю</phrase>
<phrase data="auto_be1aff65661f854c">[name]&quot; data-media-key=&quot;name&quot; value=&quot;&quot; placeholder=&quot;Название файла&quot;&gt;</phrase>
<phrase data="auto_be4a59e132257571">Отозвать API-токен?</phrase>
<phrase data="auto_be7ec45710267c5c">&lt;section class=&quot;documents-revision-content&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-forms&quot;&gt;&lt;/i&gt;&lt;b&gt;Поля рубрики&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_bea15c7bbd9e167c">е</phrase>
<phrase data="auto_beed168817322eeb">из</phrase>
<phrase data="auto_bf6379458cca6658">м</phrase>
<phrase data="auto_c13875d6c76e5c2e">ш</phrase>
<phrase data="auto_c2b70c3bb20678a7">Не удалось применить фильтры</phrase>
<phrase data="auto_c52083faa73d3829">Поле</phrase>
<phrase data="auto_c5eb26618a7ede98">Только чтение</phrase>
<phrase data="auto_c7fca87d8dd9b0e6">Нажмите, чтобы найти документ</phrase>
<phrase data="auto_c9d8ea6476aa730d">Добавить «</phrase>
<phrase data="auto_cccd51a8c6aa88c1">&lt;div class=&quot;documents-term-status&quot;&gt;&lt;i class=&quot;ti ti-loader-2&quot;&gt;&lt;/i&gt;&lt;span&gt;Ищем совпадения…&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_ccfe048a5551ed0f">пресет</phrase>
<phrase data="auto_cdd3b42f2a48df2f">Удалить пресет</phrase>
<phrase data="auto_ceebfda5155965db">Не удалось сгенерировать короткий алиас</phrase>
<phrase data="auto_cf0fc49836b59906">можно оставить пустым</phrase>
<phrase data="auto_cf412de9896e972b">Выберите хотя бы одно разрешение</phrase>
<phrase data="auto_d0888e0521387c25">Ширина,</phrase>
<phrase data="auto_d164037dc0f1bd06">Удалить ревизию</phrase>
<phrase data="auto_d2264bee0979ddf6">&lt;div&gt;&lt;dt&gt;Состояние&lt;/dt&gt;&lt;dd&gt;Загрузка...&lt;/dd&gt;&lt;/div&gt;</phrase>
<phrase data="auto_d6951b2c8c477f98">Все элементы этого поля будут удалены из документа после сохранения.</phrase>
<phrase data="auto_d6b08d92a7694cd4">Вес,</phrase>
<phrase data="auto_d77df87cd6098c73">История значений полей</phrase>
<phrase data="auto_d7907f4d435df378">Дополнительно</phrase>
<phrase data="auto_d944cde8b15c4d8b">у</phrase>
<phrase data="auto_d9c83b31f5cb2376">Не удалось проверить короткий alias</phrase>
<phrase data="auto_da97cf5fe3fc4c42">Проверка...</phrase>
<phrase data="auto_db4d819570e77e56">черновик</phrase>
<phrase data="auto_dcedec8acc4af7ca">Документ назначения</phrase>
<phrase data="auto_dd0ec9a1c7140562">Добавить файлы из папки</phrase>
<phrase data="auto_defc150c8003aa92">Ревизии удалены</phrase>
<phrase data="auto_df5763ef792cf2f4">см</phrase>
<phrase data="auto_e01aed78c667a5aa">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-value-remove&quot; type=&quot;button&quot; data-document-value-remove data-tooltip=&quot;Удалить&quot; aria-label=&quot;Удалить&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
<phrase data="auto_e576260a6a03f359">т</phrase>
<phrase data="auto_e76f1ce35ab97dfb">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;document&quot; checked&gt;&lt;span&gt;Все&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_e83e7fbf2312ab9e">Документ уже изменён. Обновите страницу.</phrase>
<phrase data="auto_e94209605c9c0a4e">&lt;button class=&quot;documents-media-thumb&quot; type=&quot;button&quot; data-document-media-pick aria-label=&quot;Выбрать файл&quot;&gt;&lt;span&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_ec6be72560e6078a">История пока пустая</phrase>
<phrase data="auto_eeb60e75b96656e7">Некорректный ответ</phrase>
<phrase data="auto_f0b532724126bbe8">Очистить все элементы поля?</phrase>
<phrase data="auto_f26e5c0c72082089">Найти или добавить ключевое слово</phrase>
<phrase data="auto_f3d123e46297531d">в</phrase>
<phrase data="auto_f42c85cd57ba4aa0">Остаться</phrase>
<phrase data="auto_f6069600bcb07eea">&lt;div class=&quot;modal-footer&quot;&gt;&lt;div class=&quot;mf-left documents-picker-count&quot; data-relation-count&gt;&lt;/div&gt;&lt;button class=&quot;btn btn-ghost&quot; type=&quot;button&quot; data-relation-close&gt;Закрыть&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_f61de80bf0ca15a4">Документ не выбран</phrase>
<phrase data="auto_f7286439b109e428">Новый редирект</phrase>
<phrase data="auto_f7806fce80e51b40">Старый URL</phrase>
<phrase data="auto_f914c65278294e4a">&lt;div class=&quot;modal-header&quot;&gt;&lt;span class=&quot;dialog-icon info&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/span&gt;&lt;div style=&quot;flex:1&quot;&gt;&lt;h3&gt;Выбрать документ&lt;/h3&gt;&lt;p class=&quot;text-secondary&quot; style=&quot;margin-top:4px&quot;&gt;</phrase>
<phrase data="auto_fa9f0524f3a0531e">Добавить тег</phrase>
<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>
<phrase data="runtime_11cef8141437f8a6">ъ</phrase>
<phrase data="runtime_15f2ccabeb425c29">й</phrase>
<phrase data="runtime_1cc10a3947f7c245">д</phrase>
<phrase data="runtime_1d51ebe0fc45e38e">Только поля</phrase>
<phrase data="runtime_259f56cb715ba3a3">е</phrase>
<phrase data="runtime_3051067c6324c63f">з</phrase>
<phrase data="runtime_30fbc377ae9122ed">ё</phrase>
<phrase data="runtime_3164696756227147">Заметка добавлена</phrase>
<phrase data="runtime_34c6fbb62da8ce5c">п</phrase>
<phrase data="runtime_37d43a57a463add5">ю</phrase>
<phrase data="runtime_40831c5d98a57dfc">ч</phrase>
<phrase data="runtime_446cdc8e3c0d7efc">ы</phrase>
<phrase data="runtime_46b0d3855548054a">JSON-снимок пересобран</phrase>
<phrase data="runtime_4f60bd4e24e7dc7f">ш</phrase>
<phrase data="runtime_50338c005c41fb4e">о</phrase>
<phrase data="runtime_5225a95b111d47bf">к</phrase>
<phrase data="runtime_53b10ee4405f56d6">ещё не создан</phrase>
<phrase data="runtime_557f4cd2c6d3c150">см</phrase>
<phrase data="runtime_61095f9d62dfe291">Документ восстановлен</phrase>
<phrase data="runtime_6228f1454b196ffd">API-токен отозван</phrase>
<phrase data="runtime_65fd24f1cb37fec4">Черновик</phrase>
<phrase data="runtime_72a81659de042887">Ревизии удалены</phrase>
<phrase data="runtime_734dbc2b54ab6c95">р</phrase>
<phrase data="runtime_754309a41e0df98a">ф</phrase>
<phrase data="runtime_7592b7ec4eba966a">ц</phrase>
<phrase data="runtime_7b0ff8a2e5fdd66d">м</phrase>
<phrase data="runtime_823c4eb3e895adc9">а</phrase>
<phrase data="runtime_85011fce4419ee63">Поле #</phrase>
<phrase data="runtime_894f97c52af9e5ac">Заметка удалена</phrase>
<phrase data="runtime_9aa032336a8e122c">б</phrase>
<phrase data="runtime_9b21f5cf014c9efb">я</phrase>
<phrase data="runtime_9b6276c9a4ac5507">Документ сохранён</phrase>
<phrase data="runtime_a1f5d39a12e8f6be">с</phrase>
<phrase data="runtime_ab1f4be7e5d6fd8b">г</phrase>
<phrase data="runtime_ae51ade91d2b85b7">Ревизия удалена</phrase>
<phrase data="runtime_b8012cb642c887a0">в</phrase>
<phrase data="runtime_b95d78565f154f82">ь</phrase>
<phrase data="runtime_bb69a96efca3303e">х</phrase>
<phrase data="runtime_bbb369f210d2969f">щ</phrase>
<phrase data="runtime_beee80c256b458da">у</phrase>
<phrase data="runtime_bf0d18ef68a8a34e">можно оставить пустым</phrase>
<phrase data="runtime_c08fb476eca49399">кг</phrase>
<phrase data="runtime_c5412ba2cc2fc45a">Пресет создания сохранён</phrase>
<phrase data="runtime_c78364c5d0f27706">Б</phrase>
<phrase data="runtime_cdd02bd8b5684812">Редирект удалён</phrase>
<phrase data="runtime_d92fff560eb5495e">л</phrase>
<phrase data="runtime_dd48804aa38b03e1">Пресет удалён</phrase>
<phrase data="runtime_dd591284d827e17f">ж</phrase>
<phrase data="runtime_e1c88af54b9fced2">Документ #</phrase>
<phrase data="runtime_e35c8bb151d3530d">Редирект сохранён</phrase>
<phrase data="runtime_e93c3d6287d39cc3">API-токен создан</phrase>
<phrase data="runtime_ee71f8405f87d786">э</phrase>
<phrase data="runtime_eead37caf818d0fa">и</phrase>
<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>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_catalog_empty">Разделы не выбраны.</phrase>
<phrase data="documents_catalog_add">Добавить раздел</phrase>
<phrase data="documents_catalog_search">Найти раздел</phrase>
<phrase data="documents_catalog_remove">Убрать раздел</phrase>
<phrase data="documents_catalog_document_prefix">документ #</phrase>
<phrase data="documents_catalog_hidden">скрыт</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')),
@@ -0,0 +1,225 @@
{% if catalog_mode and not is_new %}
<section class="product-promotions-panel" data-document-workspace-panel="promotions" data-product-promotions data-base="{{ ADMINX_BASE }}" data-product-id="{{ document.Id }}" data-product-title="{{ document.document_title|e('html_attr') }}" data-search-url="{{ ADMINX_BASE }}/catalog/product-relations/products" data-csrf="{{ csrf_token }}">
<header class="section-header blocks-panel-header product-promotions-header">
<div class="section-icon"><i class="ti ti-gift"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Акции товара</h2>
<p class="section-desc">Настройте скидку на этот или сопутствующий товар либо выберите подарок.</p>
</div>
<div class="section-header-right"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/shop/settings?tab=promotions"><i class="ti ti-adjustments"></i>Все акции</a></div>
</header>
{% if not product_promotions_available %}
<div class="alert alert-warning product-promotions-alert"><i class="ti ti-database-exclamation"></i><div><b>Таблицы акций ещё не созданы</b><p>Примените миграции модуля «Магазин».</p></div></div>
{% elseif not can_manage_product_promotions %}
<div class="alert alert-warning product-promotions-alert"><i class="ti ti-lock"></i><div><b>Недостаточно прав</b><p>Для настройки нужны права на товары и управление заказами.</p></div></div>
{% else %}
<div class="product-promotions-layout">
<section class="product-promotions-list-panel">
<div class="product-promotions-subhead"><div><h3>Правила для товара</h3><p>Простые правила можно изменить здесь. Массовые открываются в общих настройках.</p></div><span class="badge badge-blue">{{ product_promotions|length }}</span></div>
<div class="product-promotions-list" data-product-promotion-list>
{% for item in product_promotions %}
<article class="product-promotion-row{{ not item.status ? ' is-disabled' : '' }}" data-product-promotion-row="{{ item.id }}">
<span class="icon-tile" style="--tile-bg:{{ item.reward_type == 'gift' ? 'var(--violet-100)' : 'var(--green-100)' }};--tile-fg:{{ item.reward_type == 'gift' ? 'var(--violet-600)' : 'var(--green-600)' }}"><i class="ti {{ item.reward_type == 'gift' ? 'ti-gift' : 'ti-discount-2' }}"></i></span>
<div class="product-promotion-row-copy">
<b>{{ item.title }}</b>
<small>
{% if item.reward_type == 'gift' %}
Подарок: {{ item.gift_product.title|default('товар не найден') }}
{% elseif item.reward_type == 'percent' %}
Скидка {{ item.reward_value|number_format(0, '.', ' ') }}%
{% elseif item.reward_type == 'fixed_price' %}
Цена {{ item.reward_value|number_format(0, '.', ' ') }}
{% else %}
Скидка {{ item.reward_value|number_format(0, '.', ' ') }}
{% endif %}
{% if item.reward_type != 'gift' and item.reward_products %} · {{ item.reward_products|first.title }}{% endif %}
</small>
</div>
<div class="product-promotion-row-actions">
{% if item.direct_product_rule %}
<label class="switch" data-tooltip="{{ item.status ? 'Выключить акцию' : 'Включить акцию' }}"><input type="checkbox"{{ item.status ? ' checked' : '' }} data-product-promotion-toggle data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/{{ item.id }}/toggle"><span></span></label>
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-edit" type="button" data-product-promotion-edit data-rule="{{ item|json_encode|e('html_attr') }}" data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-product-promotion-delete data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/{{ item.id }}/delete" data-title="{{ item.title|e('html_attr') }}" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
{% else %}
<span class="badge badge-gray">общее правило</span>
<a class="btn btn-ghost btn-icon btn-sm" href="{{ ADMINX_BASE }}/shop/settings?tab=promotions" data-tooltip="Открыть общий конструктор" aria-label="Открыть общий конструктор"><i class="ti ti-external-link"></i></a>
{% endif %}
</div>
</article>
{% else %}
<div class="empty-state product-promotions-empty"><i class="ti ti-gift-off"></i><b>Акций для товара пока нет</b><span>Создайте скидку или выберите товар в подарок.</span></div>
{% endfor %}
</div>
</section>
<form class="product-promotion-form" method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/0" data-product-promotion-form>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="code" value="">
<input type="hidden" name="priority" value="100">
<div class="product-promotions-subhead"><div><h3 data-product-promotion-form-title>Новая акция</h3><p>Редактируемый товар уже выбран как условие.</p></div><label class="switch" data-tooltip="Акция включена"><input type="checkbox" name="status" value="1" checked><span></span></label></div>
<div class="form-grid product-promotion-fields">
<label class="field col-8"><span class="field-label">Название</span><input class="input" name="title" maxlength="190" value="Акция: {{ document.document_title|e('html_attr') }}" required></label>
<label class="field col-4"><span class="field-label">При покупке, шт.</span><input class="input" type="number" name="condition_quantity" min="1" max="1000" value="1"></label>
<label class="field col-6"><span class="field-label">Что получает покупатель</span><select class="select" name="reward_type" data-product-promotion-type><option value="percent">Скидка, %</option><option value="fixed_discount">Скидка, ₽</option><option value="fixed_price">Специальная цена</option><option value="gift">Товар в подарок</option></select></label>
<label class="field col-6" data-product-promotion-value-field><span class="field-label" data-product-promotion-value-label>Размер скидки, %</span><input class="input" type="number" name="reward_value" min="0" step="0.01" value=""></label>
<label class="field col-6" data-product-promotion-target-mode-field><span class="field-label">На какой товар действует</span><select class="select" name="target_mode" data-product-promotion-target-mode><option value="current">На этот товар</option><option value="linked">На другой товар</option></select></label>
<label class="field col-6"><span class="field-label">Количество со скидкой / в подарок</span><input class="input" type="number" name="reward_quantity" min="1" max="1000" value="1"></label>
<div class="field col-12 product-promotion-picker" data-product-promotion-picker hidden>
<input type="hidden" name="target_product_id" value="">
<span class="field-label" data-product-promotion-target-label>Сопутствующий товар</span>
<div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" autocomplete="off" placeholder="Название, артикул или ID" data-product-promotion-search></div>
<div class="product-picker-results" data-product-promotion-results hidden></div>
<div class="product-picker-selected" data-product-promotion-selected hidden></div>
</div>
<label class="field col-6"><span class="field-label">Начало</span><input class="input" type="datetime-local" name="starts_at"></label>
<label class="field col-6"><span class="field-label">Завершение</span><input class="input" type="datetime-local" name="ends_at"></label>
<label class="field col-12"><span class="field-label">Описание для покупателя</span><input class="input" name="description" maxlength="500" placeholder="Например: скидка действует при покупке вместе"></label>
<label class="product-promotion-option col-12"><span class="switch"><input type="checkbox" name="allow_coupon" value="1"><span></span></span><span><b>Разрешить применять купон</b><small>Купон будет рассчитан после акции товара.</small></span></label>
</div>
<div class="alert alert-info product-promotion-preview"><i class="ti ti-sparkles"></i><div><b>Как сработает</b><p data-product-promotion-preview>Укажите размер скидки.</p></div></div>
<div class="product-promotion-actions"><button class="btn btn-ghost" type="button" data-product-promotion-reset hidden>Отменить редактирование</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i><span data-product-promotion-submit>Создать акцию</span></button></div>
</form>
</div>
{% endif %}
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card catalog-product-variant-bridge" data-document-workspace-panel="variants">
<div class="documents-section-head"><h2>Варианты товара</h2>{% if variant_group %}<span class="badge badge-blue">Группа #{{ variant_group.id }}</span>{% endif %}</div>
<div class="documents-card-body between">
{% if variant_group %}
<div><b>{{ variant_group.title }}</b><p class="text-secondary">{{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}</p></div>
<a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/variant-groups/{{ variant_group.id }}"><i class="ti ti-box-multiple"></i>Управление вариантами</a>
{% else %}
<div><b>Товар пока не объединён с вариантами</b><p class="text-secondary">Создайте группу на основе текущего товара.</p></div>
<form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/variant-group" data-variant-action>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="title" value="{{ document.document_title|e('html_attr') }}">
<button class="btn btn-secondary" type="submit"><i class="ti ti-plus"></i>Создать группу вариантов</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="product-native-attributes" data-document-workspace-panel="attributes">
{% if native_attributes.attributes_count %}
<form class="native-attributes-form" method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/attributes" data-attribute-ajax>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<header class="section-header blocks-panel-header native-attributes-panel-header">
<div class="section-icon"><i class="ti ti-list-details"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Нативные характеристики</h2>
<p class="section-desc">Характеристики сгруппированы по назначению. Единицы измерения сохраняются вместе со схемой и не вводятся вручную.</p>
</div>
<div class="section-header-right"><span class="badge badge-violet">{{ native_attributes.attributes_count }} полей</span></div>
</header>
<div class="native-attribute-groups">
{% for group in native_attributes.groups %}
<section class="card native-attribute-group">
<div class="native-attribute-group-head"><div><b>{{ group.name }}</b><small>{{ group.set_name }}</small></div><span class="badge badge-gray">{{ group.items|length }}</span></div>
<div class="native-attribute-grid">
{% for item in group.items %}
{% if item.value_type == 'multi_choice' and item.option_items %}
<fieldset class="field native-attribute-field native-attribute-multi-field">
<legend class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}</legend>
<div class="native-attribute-option-grid">
{% for option in item.option_items %}
<label class="check native-attribute-option" data-native-attribute-option>
<input type="checkbox" name="attribute[{{ item.id }}][]" value="{{ option.value_key|e('html_attr') }}"{{ item.value is iterable and option.value_key in item.value ? ' checked' : '' }}>
{% if option.swatch %}<i class="native-attribute-option-swatch" style="--attribute-swatch:{{ option.swatch|e('html_attr') }}"></i>{% endif %}
<span><b>{{ option.label }}</b>{% if option.legacy %}<small>Прежнее значение</small>{% endif %}</span>
</label>
{% endfor %}
</div>
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span>
</fieldset>
{% else %}
<label class="field native-attribute-field{{ item.editor == 'textarea' ? ' native-attribute-textarea-field' : '' }}">
<span class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}</span>
{% if item.value_type == 'boolean' %}
<select class="select" name="attribute[{{ item.id }}]"><option value="">Не задано</option><option value="1" {{ item.value is same as(true) or item.value == '1' ? 'selected' : '' }}>Да</option><option value="0" {{ item.value is same as(false) and item.value_json != '' or item.value == '0' ? 'selected' : '' }}>Нет</option></select>
{% elseif item.value_type == 'choice' and item.option_items %}
<span class="native-attribute-choice" data-native-attribute-choice><select class="select" name="attribute[{{ item.id }}]"><option value="" data-swatch="">Не задано</option>{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" data-swatch="{{ option.swatch|e('html_attr') }}" {{ item.value == option.value_key ? 'selected' : '' }}>{{ option.label }}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select><span class="native-attribute-choice-swatch" data-native-attribute-choice-swatch hidden></span></span>
{% elseif item.value_type == 'number' %}
<span class="input-group native-attribute-input-group"><input class="input" type="number" step="any" name="attribute[{{ item.id }}]" value="{{ item.value }}">{% if item.unit %}<span class="input-addon">{{ item.unit }}</span>{% endif %}</span>
{% elseif item.value_type == 'date' %}
<input class="input" type="date" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'multi_choice' %}
<textarea class="textarea" rows="3" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join('\n') : item.value }}</textarea>
{% elseif item.editor == 'textarea' %}
<textarea class="textarea native-attribute-textarea" rows="5" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join(', ') : item.value }}</textarea>
{% else %}
{% if item.unit %}<span class="input-group native-attribute-input-group"><input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}"><span class="input-addon">{{ item.unit }}</span></span>{% else %}<input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}">{% endif %}
{% endif %}
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span>
</label>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
<div class="native-attribute-footer"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить характеристики</button></div>
</div>
</form>
{% else %}
<header class="section-header blocks-panel-header native-attributes-panel-header">
<div class="section-icon"><i class="ti ti-list-details"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Нативные характеристики</h2>
<p class="section-desc">Для раздела товара пока не назначены характеристики.</p>
</div>
</header>
<div class="card native-attribute-empty">
<div class="documents-placeholder">
<span class="icon-tile" style="--tile-bg:var(--gray-100);--tile-fg:var(--gray-600)"><i class="ti ti-list-search"></i></span>
<div><b>Характеристик пока нет</b><p class="text-secondary">Назначьте набор характеристик разделу каталога.</p></div>
<a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a>
</div>
</div>
{% endif %}
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card product-shipping-card" data-product-shipping data-document-workspace-panel="shipping">
<form data-product-shipping-form action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping" method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="packages" value="">
<div class="documents-section-head product-shipping-head">
<div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-package-export"></i></span><div><h2>Доставка и упаковка</h2><p class="text-secondary">Физические грузовые места для расчёта перевозчиками.</p></div></div>
<label class="switch"><input type="checkbox" name="shipping_enabled" value="1"{{ shipping_profile.shipping_enabled ? ' checked' : '' }}><span></span><b>Рассчитывать доставку</b></label>
</div>
<div class="documents-card-body product-shipping-body">
{% if shipping_profile.legacy %}<div class="product-shipping-legacy"><i class="ti ti-info-circle"></i><span>Найдены старые данные: {% for alias,value in shipping_profile.legacy %}<b>{{ alias }}</b> {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.</span></div>{% endif %}
<div class="product-package-template-picker"><select class="select" data-package-template-select><option value="">Добавить готовую упаковку</option>{% for template in package_templates %}<option value="{{ template.id }}" data-package="{{ template|json_encode|e('html_attr') }}">{{ template.title }} · {{ template.dimensions }} · {{ template.weight_label }}</option>{% endfor %}</select><button class="btn btn-secondary" type="button" data-package-template-apply disabled><i class="ti ti-plus"></i>Добавить</button><a class="btn btn-ghost btn-icon" href="{{ ADMINX_BASE }}/catalog/products/shipping" data-tooltip="Управление шаблонами" aria-label="Управление шаблонами упаковки"><i class="ti ti-settings"></i></a></div>
<div class="product-package-head" aria-hidden="true"><span>Грузовое место</span><span>Кол-во</span><span>Вес, кг</span><span>Длина, см</span><span>Ширина, см</span><span>Высота, см</span><span></span></div>
<div class="product-package-list" data-package-list>
{% for package in shipping_profile.packages %}
<div class="product-package-row" data-package-row>
<input class="input" name="title" value="{{ package.title|e('html_attr') }}" aria-label="Название грузового места">
<input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество">
<input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg }}" aria-label="Вес в килограммах">
<input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm }}" aria-label="Длина в сантиметрах">
<input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm }}" aria-label="Ширина в сантиметрах">
<input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm }}" aria-label="Высота в сантиметрах">
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button>
</div>
{% endfor %}
</div>
<div class="product-shipping-empty" data-package-empty{{ shipping_profile.packages ? ' hidden' : '' }}><i class="ti ti-package-off"></i><span>Грузовые места ещё не добавлены</span></div>
<div class="product-shipping-footer">
<div class="product-shipping-summary"><span><b data-package-places>{{ shipping_profile.summary.places }}</b> мест</span><span><b data-package-weight>{{ shipping_profile.summary.weight_kg|number_format(3, '.', ' ') }}</b> кг</span><span><b data-package-volume>{{ shipping_profile.summary.volume_m3|number_format(4, '.', ' ') }}</b> м³</span><span class="badge {{ shipping_profile.complete ? 'badge-green' : 'badge-amber' }}" data-package-state>{{ shipping_profile.complete ? 'данные заполнены' : 'нужны габариты' }}</span></div>
<div class="cluster">{% if variant_group %}<button class="btn btn-secondary" type="button" data-shipping-copy-variants data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping/copy-to-variants" data-csrf="{{ csrf_token }}" data-tooltip="Заменить упаковку у всех вариантов"><i class="ti ti-copy"></i>Вариантам</button>{% endif %}<button class="btn btn-secondary" type="button" data-package-add><i class="ti ti-plus"></i>Добавить место</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить упаковку</button></div>
</div>
</div>
</form>
<template data-package-template><div class="product-package-row" data-package-row><input class="input" name="title" value="" placeholder="Коробка 1" aria-label="Название грузового места"><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="1" aria-label="Количество"><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="" placeholder="0" aria-label="Вес в килограммах"><input class="input" type="number" name="length_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Длина в сантиметрах"><input class="input" type="number" name="width_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Ширина в сантиметрах"><input class="input" type="number" name="height_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Высота в сантиметрах"><button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
</section>
{% endif %}
+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 %}
+94 -65
View File
@@ -1,5 +1,5 @@
{% extends '@adminx/main.twig' %}
{% block title %}{{ catalog_mode ? 'Товар #' ~ document.Id : (is_new ? 'Новый документ' : 'Документ #' ~ document.Id) }}{% endblock %}
{% block title %}{{ catalog_mode ? (is_new ? 'Новый товар' : 'Товар #' ~ document.Id) : (is_new ? 'Новый документ' : 'Документ #' ~ document.Id) }}{% endblock %}
{% block content %}
<nav class="breadcrumbs" aria-label="Хлебные крошки">
@@ -11,72 +11,62 @@
<div class="page-header documents-edit-header">
<div class="between">
<div>
<h1 data-document-edit-heading>{{ is_new ? 'Новый документ' : document.document_title }}</h1>
<p class="text-secondary" style="margin-top:5px" data-document-edit-meta>{{ is_new ? 'Создание базовой карточки документа.' : 'ID ' ~ document.Id ~ ', ' ~ document.state_label ~ ', изменён: ' ~ document.changed_label }}</p>
<h1 data-document-edit-heading>{{ is_new ? (catalog_mode ? 'Новый товар' : 'Новый документ') : document.document_title }}</h1>
<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">
<div class="segmented documents-editor-modes" role="group" aria-label="Режим редактора">
<button class="segmented-item" type="button" data-document-editor-mode="quick" data-tooltip="Только основное и поля"><i class="ti ti-bolt"></i><span>Быстро</span></button>
<button class="segmented-item" type="button" data-document-editor-mode="normal" data-tooltip="Основное, поля и публикация"><i class="ti ti-layout-columns"></i><span>Обычно</span></button>
<button class="segmented-item" type="button" data-document-editor-mode="advanced" data-tooltip="Все настройки документа"><i class="ti ti-adjustments-horizontal"></i><span>Расширенно</span></button>
</div>
{% 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>
</div>
{% if catalog_mode and not is_new %}<section class="card documents-card catalog-product-variant-bridge"><div class="documents-section-head"><h2>Варианты товара</h2>{% if variant_group %}<span class="badge badge-blue">Группа #{{ variant_group.id }}</span>{% endif %}</div><div class="documents-card-body between">{% if variant_group %}<div><b>{{ variant_group.title }}</b><p class="text-secondary">{{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}</p></div><a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/variant-groups/{{ variant_group.id }}"><i class="ti ti-box-multiple"></i>Управление вариантами</a>{% else %}<div><b>Товар пока не объединён с вариантами</b><p class="text-secondary">Создайте группу на основе текущего товара.</p></div><form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/variant-group" data-variant-action><input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="title" value="{{ document.document_title|e('html_attr') }}"><button class="btn btn-secondary" type="submit"><i class="ti ti-plus"></i>Создать группу вариантов</button></form>{% endif %}</div></section>{% endif %}
{% if catalog_mode and not is_new and native_attributes.attributes_count %}
<section class="card documents-card product-native-attributes"><form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/attributes" data-attribute-ajax><input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="documents-section-head"><div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-details"></i></span><div><h2>Нативные характеристики</h2><p class="text-secondary">Новый контур проверки. Публичная карточка пока использует старые поля.</p></div></div><span class="badge badge-violet">{{ native_attributes.attributes_count }}</span></div>
<div class="documents-card-body native-attribute-groups">{% for group in native_attributes.groups %}<section class="native-attribute-group"><div class="native-attribute-group-head"><div><b>{{ group.name }}</b><small>{{ group.set_name }}</small></div><span>{{ group.items|length }}</span></div><div class="native-attribute-grid">{% for item in group.items %}<label class="field native-attribute-field"><span class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}{% if item.unit %}<small>{{ item.unit }}</small>{% endif %}</span>
{% if item.value_type == 'boolean' %}<select class="select" name="attribute[{{ item.id }}]"><option value="">Не задано</option><option value="1" {{ item.value is same as(true) or item.value == '1' ? 'selected' : '' }}>Да</option><option value="0" {{ item.value is same as(false) and item.value_json != '' or item.value == '0' ? 'selected' : '' }}>Нет</option></select>
{% elseif item.value_type == 'choice' and item.option_items %}<span class="native-attribute-choice" data-native-attribute-choice><select class="select" name="attribute[{{ item.id }}]"><option value="" data-swatch="">Не задано</option>{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" data-swatch="{{ option.swatch|e('html_attr') }}" {{ item.value == option.value_key ? 'selected' : '' }}>{{ option.label }}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select><span class="native-attribute-choice-swatch" data-native-attribute-choice-swatch hidden></span></span>
{% elseif item.value_type == 'number' %}<input class="input" type="number" step="any" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'date' %}<input class="input" type="date" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'multi_choice' and item.option_items %}<select class="select native-attribute-multi" name="attribute[{{ item.id }}][]" multiple size="{{ item.option_items|length > 6 ? 6 : item.option_items|length }}">{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" {{ item.value is iterable and option.value_key in item.value ? 'selected' : '' }}>{{ option.label }}{% if option.swatch %} · {{ option.swatch }}{% endif %}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select>
{% elseif item.value_type == 'multi_choice' %}<textarea class="textarea" rows="3" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join('\n') : item.value }}</textarea>
{% else %}<input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}">{% endif %}
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span></label>{% endfor %}</div></section>{% endfor %}<div class="native-attribute-footer"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить характеристики</button></div></div>
</form></section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card product-shipping-card" data-product-shipping>
<form data-product-shipping-form action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping" method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="packages" value="">
<div class="documents-section-head product-shipping-head">
<div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-package-export"></i></span><div><h2>Доставка и упаковка</h2><p class="text-secondary">Физические грузовые места для расчёта перевозчиками.</p></div></div>
<label class="switch"><input type="checkbox" name="shipping_enabled" value="1"{{ shipping_profile.shipping_enabled ? ' checked' : '' }}><span></span><b>Рассчитывать доставку</b></label>
<div class="documents-editor-workspace" data-document-workspace data-active-panel="main" data-catalog-mode="{{ catalog_mode ? '1' : '0' }}" data-is-new="{{ is_new ? '1' : '0' }}">
<div class="documents-workspace-navigation">
<div class="tabs documents-workspace-tabs" role="tablist" aria-label="{{ catalog_mode ? 'Разделы товара' : 'Разделы документа' }}">
<button class="tab is-active" type="button" role="tab" aria-selected="true" data-document-workspace-tab="main"><i class="ti ti-file-description"></i>Основное</button>
{% if catalog_mode %}
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="promotions"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-gift"></i>Акции</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="attributes"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-list-details"></i>Характеристики</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="variants"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-box-multiple"></i>Варианты</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="shipping"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-truck-delivery"></i>Доставка</button>
{% endif %}
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="additional"><i class="ti ti-adjustments-horizontal"></i>Дополнительно</button>
</div>
<div class="documents-card-body product-shipping-body">
{% if shipping_profile.legacy %}<div class="product-shipping-legacy"><i class="ti ti-info-circle"></i><span>Найдены старые данные: {% for alias,value in shipping_profile.legacy %}<b>{{ alias }}</b> {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.</span></div>{% endif %}
<div class="product-package-template-picker"><select class="select" data-package-template-select><option value="">Добавить готовую упаковку</option>{% for template in package_templates %}<option value="{{ template.id }}" data-package="{{ template|json_encode|e('html_attr') }}">{{ template.title }} · {{ template.dimensions }} · {{ template.weight_label }}</option>{% endfor %}</select><button class="btn btn-secondary" type="button" data-package-template-apply disabled><i class="ti ti-plus"></i>Добавить</button><a class="btn btn-ghost btn-icon" href="{{ ADMINX_BASE }}/catalog/products/shipping" data-tooltip="Управление шаблонами" aria-label="Управление шаблонами упаковки"><i class="ti ti-settings"></i></a></div>
<div class="product-package-head" aria-hidden="true"><span>Грузовое место</span><span>Кол-во</span><span>Вес, кг</span><span>Длина, см</span><span>Ширина, см</span><span>Высота, см</span><span></span></div>
<div class="product-package-list" data-package-list>
{% for package in shipping_profile.packages %}
<div class="product-package-row" data-package-row>
<input class="input" name="title" value="{{ package.title|e('html_attr') }}" aria-label="Название грузового места">
<input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество">
<input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg }}" aria-label="Вес в килограммах">
<input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm }}" aria-label="Длина в сантиметрах">
<input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm }}" aria-label="Ширина в сантиметрах">
<input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm }}" aria-label="Высота в сантиметрах">
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button>
</div>
{% endfor %}
</div>
<div class="product-shipping-empty" data-package-empty{{ shipping_profile.packages ? ' hidden' : '' }}><i class="ti ti-package-off"></i><span>Грузовые места ещё не добавлены</span></div>
<div class="product-shipping-footer"><div class="product-shipping-summary"><span><b data-package-places>{{ shipping_profile.summary.places }}</b> мест</span><span><b data-package-weight>{{ shipping_profile.summary.weight_kg|number_format(3, '.', ' ') }}</b> кг</span><span><b data-package-volume>{{ shipping_profile.summary.volume_m3|number_format(4, '.', ' ') }}</b> м³</span><span class="badge {{ shipping_profile.complete ? 'badge-green' : 'badge-amber' }}" data-package-state>{{ shipping_profile.complete ? 'данные заполнены' : 'нужны габариты' }}</span></div><div class="cluster">{% if variant_group %}<button class="btn btn-secondary" type="button" data-shipping-copy-variants data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping/copy-to-variants" data-csrf="{{ csrf_token }}" data-tooltip="Заменить упаковку у всех вариантов"><i class="ti ti-copy"></i>Вариантам</button>{% endif %}<button class="btn btn-secondary" type="button" data-package-add><i class="ti ti-plus"></i>Добавить место</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить упаковку</button></div></div>
</div>
</form>
<template data-package-template><div class="product-package-row" data-package-row><input class="input" name="title" value="" placeholder="Коробка 1" aria-label="Название грузового места"><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="1" aria-label="Количество"><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="" placeholder="0" aria-label="Вес в килограммах"><input class="input" type="number" name="length_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Длина в сантиметрах"><input class="input" type="number" name="width_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Ширина в сантиметрах"><input class="input" type="number" name="height_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Высота в сантиметрах"><button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
</section>
{% endif %}
<label class="field documents-workspace-mobile">
<span class="field-label">{{ catalog_mode ? 'Раздел товара' : 'Раздел документа' }}</span>
<select class="select" data-document-workspace-select>
<option value="main">Основное</option>
{% if catalog_mode %}
<option value="promotions"{{ is_new ? ' disabled' : '' }}>Акции{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="attributes"{{ is_new ? ' disabled' : '' }}>Характеристики{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="variants"{{ is_new ? ' disabled' : '' }}>Варианты{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="shipping"{{ is_new ? ' disabled' : '' }}>Доставка{{ is_new ? ' · после сохранения' : '' }}</option>
{% endif %}
<option value="additional">Дополнительно</option>
</select>
</label>
</div>
<form id="documentForm" class="documents-edit-form" data-base="{{ ADMINX_BASE }}" data-id="{{ document.Id }}" data-rubric-id="{{ document.rubric_id }}" data-actor-id="{{ actor_id|default(0) }}" data-submit-url="{{ submit_url|default('') }}" data-return-url="{{ return_url|default('') }}" data-stay-url-template="{{ stay_url_template|default('') }}" data-quick-edit="{{ quick_edit ? '1' : '0' }}" data-editor-mode="normal">
{% if catalog_mode and not is_new and product_readiness.items %}
<section class="product-readiness-strip" aria-label="Готовность товара">
<div class="product-readiness-summary">
<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-checklist"></i></span>
<span><b>Готовность товара</b><small>{{ product_readiness.attention ? product_readiness.attention ~ ' раздела требуют внимания' : 'Обязательные данные заполнены' }}</small></span>
</div>
<div class="product-readiness-items">
{% for item in product_readiness.items %}
<button class="product-readiness-item is-{{ item.state }}" type="button" data-document-workspace-open="{{ item.panel }}">
<i class="ti {{ item.icon }}"></i>
<span><b>{{ item.title }}</b><small>{{ item.label }}</small></span>
<i class="ti {{ item.state == 'ready' ? 'ti-circle-check-filled' : (item.state == 'warning' ? 'ti-alert-circle-filled' : 'ti-chevron-right') }} product-readiness-state"></i>
</button>
{% endfor %}
</div>
</section>
{% endif %}
<form id="documentForm" class="documents-edit-form" data-base="{{ ADMINX_BASE }}" data-id="{{ document.Id }}" data-rubric-id="{{ document.rubric_id }}" data-actor-id="{{ actor_id|default(0) }}" data-submit-url="{{ submit_url|default('') }}" data-return-url="{{ return_url|default('') }}" data-stay-url-template="{{ stay_url_template|default('') }}" data-quick-edit="{{ quick_edit ? '1' : '0' }}">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="id" value="{{ document.Id }}">
<input type="hidden" name="document_version" value="{{ document.document_version|default(1) }}">
@@ -101,7 +91,7 @@
</section>
{% endif %}
<div class="documents-edit-top" data-editor-level="advanced">
<div class="documents-edit-top" data-document-section="additional">
<section class="card documents-card documents-edit-card" style="--sec:var(--amber-600);--sec-soft:var(--amber-100)">
<div class="documents-section-head">
<div class="documents-head-left">
@@ -172,7 +162,7 @@
<div class="documents-edit-layout">
<div class="stack documents-edit-main">
<section class="card documents-card documents-edit-card documents-section-main" style="--sec:var(--blue-600);--sec-soft:var(--blue-100)">
<section class="card documents-card documents-edit-card documents-section-main" data-document-section="main" style="--sec:var(--blue-600);--sec-soft:var(--blue-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-file-text"></i></span>
@@ -231,7 +221,7 @@
</div>
</section>
<section class="card documents-card documents-edit-card documents-section-seo" data-editor-level="advanced" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<section class="card documents-card documents-edit-card documents-section-seo" data-document-section="additional" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-search"></i></span>
@@ -294,7 +284,7 @@
</div>
<aside class="stack documents-edit-side">
<section class="card documents-card documents-edit-card documents-section-publish" data-editor-level="normal" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<section class="card documents-card documents-edit-card documents-section-publish" data-document-section="main" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-rocket"></i></span>
@@ -321,7 +311,7 @@
</div>
</section>
<section class="card documents-card documents-edit-card documents-section-dates" data-editor-level="advanced" style="--sec:var(--teal-600);--sec-soft:var(--teal-100)">
<section class="card documents-card documents-edit-card documents-section-dates" data-document-section="additional" style="--sec:var(--teal-600);--sec-soft:var(--teal-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-calendar-event"></i></span>
@@ -339,7 +329,7 @@
</aside>
</div>
<section class="documents-fields-section documents-section-fields" style="--sec:var(--violet-600);--sec-soft:var(--violet-100)">
<section class="documents-fields-section documents-section-fields" data-document-section="main" style="--sec:var(--violet-600);--sec-soft:var(--violet-100)">
<div class="documents-fields-heading">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-forms"></i></span>
@@ -374,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>
@@ -394,6 +384,42 @@
</div>
</section>
{% if not is_new %}
<section class="card documents-card documents-edit-card documents-relations-section" data-document-section="additional" style="--sec:var(--cyan-600);--sec-soft:var(--cyan-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-arrows-exchange"></i></span>
<div><h2>Связи документов</h2><p>Прямые и обратные связи из полей документов.</p></div>
</div>
<span class="badge badge-cyan">{{ document_relations.outgoing|length + document_relations.incoming|length }}</span>
</div>
<div class="documents-card-body documents-relations-grid">
<div class="documents-relation-list">
<div class="documents-relation-title"><b>Этот документ ссылается</b><span>{{ document_relations.outgoing|length }}</span></div>
{% for relation in document_relations.outgoing %}
<a class="documents-relation-row" href="{{ ADMINX_BASE }}/documents/{{ relation.target_document_id }}/edit">
<span><b>{{ relation.target_title ?: 'Документ #' ~ relation.target_document_id }}</b><small>{{ relation.target_rubric_title }} · {{ relation.field_title ?: relation.field_alias }}</small></span>
<i class="ti ti-arrow-right"></i>
</a>
{% else %}
<p class="text-secondary text-sm">Исходящих связей нет.</p>
{% endfor %}
</div>
<div class="documents-relation-list">
<div class="documents-relation-title"><b>На этот документ ссылаются</b><span>{{ document_relations.incoming|length }}</span></div>
{% for relation in document_relations.incoming %}
<a class="documents-relation-row" href="{{ ADMINX_BASE }}/documents/{{ relation.source_document_id }}/edit">
<span><b>{{ relation.source_title ?: 'Документ #' ~ relation.source_document_id }}</b><small>{{ relation.source_rubric_title }} · {{ relation.field_title ?: relation.field_alias }}</small></span>
<i class="ti ti-arrow-right"></i>
</a>
{% else %}
<p class="text-secondary text-sm">Обратных связей нет.</p>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<div class="sticky-actions documents-sticky-actions">
<div class="documents-save-state" data-document-save-state data-state="saved" role="status" aria-live="polite">
<span class="documents-save-state-icon"><i class="ti ti-circle-check" data-document-save-state-icon></i></span>
@@ -407,6 +433,9 @@
</div>
</form>
{% include '@documents/_product_panels.twig' %}
</div>
{% if can_manage %}
<aside class="drawer drawer-right drawer-lg documents-preset-panel" id="documentPresetDrawer" role="dialog" aria-modal="true" aria-labelledby="documentPresetDrawerTitle" hidden>
<form data-document-preset-form action="{{ ADMINX_BASE }}/documents/{{ document.Id }}/presets" method="post">

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