mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 00:45:44 +00:00
AVE.cms 3.3 build 0.35
This commit is contained in:
@@ -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,13 @@
|
||||
/tmp/
|
||||
/uploads/
|
||||
/storage/backups/
|
||||
/storage/importprice/
|
||||
/storage/imports/
|
||||
/storage/logs/
|
||||
/storage/releases/
|
||||
/storage/runtime/
|
||||
/storage/secrets/
|
||||
/storage/tmp/
|
||||
/storage/updates/
|
||||
/storage/installed.lock
|
||||
/sample/
|
||||
|
||||
+370
-236
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# AVE.cms
|
||||
|
||||
**AVE.cms 3.3 build 0.32** — модульная система управления сайтами на PHP.
|
||||
**AVE.cms 3.3 build 0.35** — модульная система управления сайтами на PHP.
|
||||
Она подходит для проектов, в которых структуру содержимого нужно собирать из
|
||||
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
|
||||
привязываясь к заранее заданному типу сайта.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Реестр провайдеров административного поиска.
|
||||
*
|
||||
* 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' => 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
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(?:/.*)?$#', 'Публичный сайт', 'Показывает, из каких базовых компонентов собирается сайт и где используется каждый из них.', array('Раздел является картой и не дублирует редакторы шаблонов, рубрик, запросов, блоков и меню.', 'Представления — дополнительный способ оформить уже подготовленные данные.', 'Диагностика сообщает только о явных пробелах в обязательных настройках.'), 'Начните со вкладки «Структура», а содержимое меняйте в штатном редакторе нужного компонента.', 'content/public-site'),
|
||||
|
||||
self::item('#^/catalog/products/shipping$#', 'Доставка и упаковка', 'Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа.', array('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'),
|
||||
self::item('#^/catalog/products(?:/.*)?$#', 'Товары каталога', 'Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории.', array('Переиндексация обновляет проекцию товара из полей документа.', 'Редактор товара остаётся редактором документа, но открывается из каталога.', 'Фильтры списка не меняют данные товара.'), 'После изменения каталоговых полей индекс обычно обновляется автоматически.'),
|
||||
@@ -90,6 +94,8 @@
|
||||
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'),
|
||||
@@ -113,7 +119,7 @@
|
||||
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, БД или инфраструктуры.', 'Тест создаёт кратковременную нагрузку и сохраняет историю запусков.'), 'Оценивайте не только общий балл, но и конкретный медленный этап.'),
|
||||
|
||||
+197
-11
@@ -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 {
|
||||
|
||||
@@ -591,7 +591,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 +600,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>' +
|
||||
|
||||
@@ -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);})();
|
||||
@@ -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
@@ -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 || '';
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
'code' => 'admin_panel',
|
||||
'group_code' => 'core',
|
||||
'name' => 'Доступ в панель управления',
|
||||
'description' => 'Разрешает вход в административную панель /adminx.',
|
||||
'description' => 'Разрешает вход в панель управления.',
|
||||
'sort_order' => 1,
|
||||
),
|
||||
array(
|
||||
|
||||
@@ -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);
|
||||
@@ -189,6 +195,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 +221,7 @@
|
||||
|
||||
Twig::addGlobals([
|
||||
'nav_items' => $navItems,
|
||||
'command_palette_items' => $commandPaletteItems,
|
||||
'user_name' => $authUser['name'] ?? '',
|
||||
'user_email' => $authUser['email'] ?? '',
|
||||
'user_role' => $authUser['role'] ?? '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,47 @@
|
||||
|
||||
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'] : '';
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -217,7 +217,6 @@
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.catalog-field-groups {
|
||||
display: grid;
|
||||
@@ -299,7 +298,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 +457,6 @@
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.catalog-recompile-errors {
|
||||
margin-top: 14px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,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>
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
.customers-table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
.customers-table td:first-child {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.customer-editor-drawer {
|
||||
width: min(66.666vw, 1240px);
|
||||
max-width: none;
|
||||
@@ -83,7 +80,6 @@
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
'size' => filesize($path),
|
||||
'size_h' => Model::human(filesize($path)),
|
||||
'mtime' => filemtime($path),
|
||||
'own' => strpos($f, 'adminx_') === 0,
|
||||
'own' => self::isManagedName($f),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,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 +151,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 +169,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();
|
||||
@@ -205,6 +205,13 @@
|
||||
return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes);
|
||||
}
|
||||
|
||||
/** Новые имена 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)
|
||||
{
|
||||
$portableTable = Model::portableTableName($table);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -151,7 +151,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>
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -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.0',
|
||||
'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',
|
||||
),
|
||||
);
|
||||
@@ -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 %}
|
||||
@@ -21,6 +21,7 @@
|
||||
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;
|
||||
@@ -36,6 +37,7 @@
|
||||
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;
|
||||
@@ -174,7 +176,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())
|
||||
@@ -240,6 +246,7 @@
|
||||
'can_manage' => true,
|
||||
'quick_edit' => Request::getBool('quick_edit', false),
|
||||
'actor_id' => Auth::id(),
|
||||
'document_relations' => DocumentRelationIndex::describe((int) $item['Id']),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -357,27 +364,31 @@
|
||||
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 store(array $params = array())
|
||||
@@ -389,7 +400,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 +413,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 +442,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 +505,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 +537,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,10 +550,11 @@
|
||||
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);
|
||||
}
|
||||
@@ -559,7 +579,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) {
|
||||
|
||||
@@ -32,10 +32,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 +65,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 +110,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 +330,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 +339,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 +353,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 +390,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 +441,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 +449,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 +528,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 +721,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 +754,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 +813,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 +948,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 +1327,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. */
|
||||
@@ -2185,14 +2212,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 +2243,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 +2265,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>';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1184,20 +1198,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 +1395,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 +1636,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;
|
||||
@@ -1925,7 +1958,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 +2061,6 @@
|
||||
.documents-relation-empty {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.documents-relation-empty {
|
||||
margin: 0;
|
||||
@@ -2106,7 +2137,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;
|
||||
@@ -2520,7 +2550,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,
|
||||
@@ -2787,7 +2816,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 +3052,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 +3150,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 +3169,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 +3562,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]');
|
||||
@@ -201,7 +203,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 +213,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) {
|
||||
@@ -376,7 +400,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 +760,8 @@
|
||||
|
||||
applyFilters: function (form, push) {
|
||||
if (!form) { return; }
|
||||
clearTimeout(this.filterTimer);
|
||||
this.filterTimer = null;
|
||||
this.applyFilterUrl(this.filterUrl(form), push);
|
||||
},
|
||||
|
||||
@@ -1399,33 +1425,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 +1625,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 +2153,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 +2193,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 () {
|
||||
|
||||
@@ -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') : '';
|
||||
|
||||
@@ -1,47 +1,290 @@
|
||||
<?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"><button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button></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"><div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Loading...</div><div class="documents-relation-list" data-relation-list></div></div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button></phrase>
|
||||
<phrase data="auto_22dc89ab84f07324">"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="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"><section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Basic settings</b><span></phrase>
|
||||
<phrase data="auto_292688e9915e161b">[link]" data-media-key="link" value="" placeholder="Link or document" data-document-media-url data-document-picker-type="all"></phrase>
|
||||
<phrase data="auto_29f3b29df0d0e54f">Restore</phrase>
|
||||
<phrase data="auto_2a9790fd6fab2922"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Select document" aria-label="Select document"><i class="ti ti-file-search"></i></button></div></phrase>
|
||||
<phrase data="auto_2ba58b00b4c52021"></span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>All</span></label></div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button></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"><span class="badge badge-gray">not created</span></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"><div><dt>File</dt><dd class="mono"></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"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti-paperclip"></i></button></phrase>
|
||||
<phrase data="auto_600363a73de5f3a5">Rebuild</phrase>
|
||||
<phrase data="auto_60512114c18fd5ed"><span class="badge badge-amber">needs reassembly</span></phrase>
|
||||
<phrase data="auto_619742ac4a834c45">doc.</phrase>
|
||||
<phrase data="auto_63b1fa390e91f75c">0 B</phrase>
|
||||
<phrase data="auto_65b7f1673713a00c">d</phrase>
|
||||
<phrase data="auto_66d207b9ebe9e4e3"><div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Failed to load options</span></div></phrase>
|
||||
<phrase data="auto_6702cc12ca25aff1"><div class="empty-state">There are no revisions yet. The first snapshot will appear after saving the document.</div></phrase>
|
||||
<phrase data="auto_691245657a52bdce"><div><label class="documents-revision-check" aria-label="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 "</phrase>
|
||||
<phrase data="auto_6e52ded051aa402d">Withdrawn</phrase>
|
||||
<phrase data="auto_6f658622a374b8d6"></p></div><button class="modal-close" type="button" data-relation-close aria-label="Close"><i class="ti ti-x"></i></button></div></phrase>
|
||||
<phrase data="auto_6fe63eee472a5a93"><span class="badge badge-green">relevant</span></phrase>
|
||||
<phrase data="auto_707a724dce1fa0d4">URL copied</phrase>
|
||||
<phrase data="auto_72c618d46424c93a">There are no saved values yet</phrase>
|
||||
<phrase data="auto_736a75710565c2f2">[description]" data-media-key="description" rows="2" placeholder="Description"></textarea></phrase>
|
||||
<phrase data="auto_75285ce97dc1dc97"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button></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"><div><dt>State</dt><dd></phrase>
|
||||
<phrase data="auto_83c8e08533872aff"><div class="empty-state">Loading...</div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button></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]" data-media-key="title" value="" placeholder="Image title"></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"><div><dt>Formed by</dt><dd></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"><div><dt>Size</dt><dd></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"><button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button></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"><div><dt>Fields</dt><dd class="mono"></phrase>
|
||||
<phrase data="auto_b2ac54889c1e667e">Batch rebuild stopped</phrase>
|
||||
<phrase data="auto_b36631e2d2874351"><article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="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]" data-media-key="name" value="" placeholder="File name"></phrase>
|
||||
<phrase data="auto_be4a59e132257571">Revoke an API token?</phrase>
|
||||
<phrase data="auto_be7ec45710267c5c"><section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Category fields</b><span></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 "</phrase>
|
||||
<phrase data="auto_cccd51a8c6aa88c1"><div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Looking for matches...</span></div></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"><div><dt>Status</dt><dd>Loading...</dd></div></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"><button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button></phrase>
|
||||
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
|
||||
<phrase data="auto_e576260a6a03f359">t</phrase>
|
||||
<phrase data="auto_e76f1ce35ab97dfb"></span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>All</span></label></div></phrase>
|
||||
<phrase data="auto_e83e7fbf2312ab9e">The document has already been changed. Refresh the page.</phrase>
|
||||
<phrase data="auto_e94209605c9c0a4e"><button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Select file"><span><i class="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"><div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Close</button></div></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"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Select document</h3><p class="text-secondary" style="margin-top:4px"></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="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>
|
||||
</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>
|
||||
@@ -1,47 +1,290 @@
|
||||
<?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"><button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></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"><div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Загрузка...</div><div class="documents-relation-list" data-relation-list></div></div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button></phrase>
|
||||
<phrase data="auto_22dc89ab84f07324">"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="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"><section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Основные настройки</b><span></phrase>
|
||||
<phrase data="auto_292688e9915e161b">[link]" data-media-key="link" value="" placeholder="Ссылка или документ" data-document-media-url data-document-picker-type="all"></phrase>
|
||||
<phrase data="auto_29f3b29df0d0e54f">Восстановить</phrase>
|
||||
<phrase data="auto_2a9790fd6fab2922"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Выбрать документ" aria-label="Выбрать документ"><i class="ti ti-file-search"></i></button></div></phrase>
|
||||
<phrase data="auto_2ba58b00b4c52021"></span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>Все</span></label></div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button></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"><span class="badge badge-gray">не создан</span></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"><div><dt>Файл</dt><dd class="mono"></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"><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-paperclip"></i></button></phrase>
|
||||
<phrase data="auto_600363a73de5f3a5">Пересобрать</phrase>
|
||||
<phrase data="auto_60512114c18fd5ed"><span class="badge badge-amber">нужна пересборка</span></phrase>
|
||||
<phrase data="auto_619742ac4a834c45">док.</phrase>
|
||||
<phrase data="auto_63b1fa390e91f75c">0 Б</phrase>
|
||||
<phrase data="auto_65b7f1673713a00c">д</phrase>
|
||||
<phrase data="auto_66d207b9ebe9e4e3"><div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Не удалось загрузить варианты</span></div></phrase>
|
||||
<phrase data="auto_6702cc12ca25aff1"><div class="empty-state">Ревизий пока нет. Первый снимок появится после сохранения документа.</div></phrase>
|
||||
<phrase data="auto_691245657a52bdce"><div><label class="documents-revision-check" aria-label="Восстановить</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"></p></div><button class="modal-close" type="button" data-relation-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div></phrase>
|
||||
<phrase data="auto_6fe63eee472a5a93"><span class="badge badge-green">актуален</span></phrase>
|
||||
<phrase data="auto_707a724dce1fa0d4">URL скопирован</phrase>
|
||||
<phrase data="auto_72c618d46424c93a">Сохранённых значений пока нет</phrase>
|
||||
<phrase data="auto_736a75710565c2f2">[description]" data-media-key="description" rows="2" placeholder="Описание"></textarea></phrase>
|
||||
<phrase data="auto_75285ce97dc1dc97"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button></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"><div><dt>Состояние</dt><dd></phrase>
|
||||
<phrase data="auto_83c8e08533872aff"><div class="empty-state">Загрузка...</div></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"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button></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]" data-media-key="title" value="" placeholder="Заголовок изображения"></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"><div><dt>Сформирован</dt><dd></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"><div><dt>Размер</dt><dd></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"><button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button></phrase>
|
||||
<phrase data="auto_b0a419827426861b">Пресет удалён</phrase>
|
||||
<phrase data="auto_b0d827d33fc8eb3f">Снимки</phrase>
|
||||
<phrase data="auto_b1c30d9335b4ffb7">/ + alias документа. Дата берётся из публикации.</phrase>
|
||||
<phrase data="auto_b279170b217c1e48"><div><dt>Поля</dt><dd class="mono"></phrase>
|
||||
<phrase data="auto_b2ac54889c1e667e">Пакетная пересборка остановлена</phrase>
|
||||
<phrase data="auto_b36631e2d2874351"><article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Восстановить</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]" data-media-key="name" value="" placeholder="Название файла"></phrase>
|
||||
<phrase data="auto_be4a59e132257571">Отозвать API-токен?</phrase>
|
||||
<phrase data="auto_be7ec45710267c5c"><section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Поля рубрики</b><span></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"><div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Ищем совпадения…</span></div></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"><div><dt>Состояние</dt><dd>Загрузка...</dd></div></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"><button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></phrase>
|
||||
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
|
||||
<phrase data="auto_e576260a6a03f359">т</phrase>
|
||||
<phrase data="auto_e76f1ce35ab97dfb"></span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>Все</span></label></div></phrase>
|
||||
<phrase data="auto_e83e7fbf2312ab9e">Документ уже изменён. Обновите страницу.</phrase>
|
||||
<phrase data="auto_e94209605c9c0a4e"><button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Выбрать файл"><span><i class="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"><div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Закрыть</button></div></phrase>
|
||||
<phrase data="auto_f61de80bf0ca15a4">Документ не выбран</phrase>
|
||||
<phrase data="auto_f7286439b109e428">Новый редирект</phrase>
|
||||
<phrase data="auto_f7806fce80e51b40">Старый URL</phrase>
|
||||
<phrase data="auto_f914c65278294e4a"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Выбрать документ</h3><p class="text-secondary" style="margin-top:4px"></phrase>
|
||||
<phrase data="auto_fa9f0524f3a0531e">Добавить тег</phrase>
|
||||
<phrase data="auto_fc8b3939cc9dc467">Путь будет записан в поле документа.</phrase>
|
||||
<phrase data="auto_fda74d41e3311f87">На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно.</phrase>
|
||||
<phrase data="auto_ffec67789fb1fcdc">щ</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>
|
||||
</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>
|
||||
@@ -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,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,61 @@
|
||||
<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>
|
||||
<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 +90,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 +161,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 +220,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 +283,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 +310,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 +328,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>
|
||||
@@ -394,6 +383,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 +432,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">
|
||||
|
||||
@@ -33,12 +33,13 @@
|
||||
$q = Request::getStr('q', '');
|
||||
$limit = Request::getInt('limit', 300);
|
||||
$sourceDef = Model::source($source);
|
||||
$rows = Model::rows($sourceDef['code'], $q, $limit);
|
||||
|
||||
return $this->render('@events/index.twig', array(
|
||||
'sources' => Model::summaries(),
|
||||
'active_source' => $sourceDef['code'],
|
||||
'active_source_def' => $sourceDef,
|
||||
'rows' => Model::rows($sourceDef['code'], $q, $limit),
|
||||
'rows' => $rows,
|
||||
'q' => $q,
|
||||
'limit' => $limit,
|
||||
'can_manage' => Permission::check('manage_events'),
|
||||
@@ -56,7 +57,7 @@
|
||||
return $this->error('Этот источник нельзя очистить', array(), 422);
|
||||
}
|
||||
|
||||
return $this->success('Журнал очищен', array(
|
||||
return $this->success($source === 'audit' ? 'Аудит очищен' : 'Журнал очищен', array(
|
||||
'redirect' => $this->base() . '/events?source=' . rawurlencode($source),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
'audit' => array(
|
||||
'code' => 'audit',
|
||||
'label' => 'Аудит',
|
||||
'description' => 'Административные действия в панели управления.',
|
||||
'description' => 'История административных действий с пользователями, объектами и техническими деталями.',
|
||||
'icon' => 'ti ti-shield-check',
|
||||
'tile_bg' => 'var(--blue-100)',
|
||||
'tile_fg' => 'var(--blue-600)',
|
||||
@@ -155,7 +155,7 @@
|
||||
AuditLog::record('events.audit_cleared', array(
|
||||
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
|
||||
'target_type' => 'audit_log',
|
||||
'meta' => array('deleted_rows' => $count),
|
||||
'meta' => array('deleted_rows' => $count, 'source' => $source['code']),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
@@ -226,20 +226,24 @@
|
||||
{
|
||||
$rows = DB::query(
|
||||
'SELECT * FROM ' . AuditLog::table() . ' ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit
|
||||
)->getAll();
|
||||
)->getAll() ?: array();
|
||||
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$meta = self::decodeMeta(isset($row['meta']) ? $row['meta'] : '');
|
||||
$state = self::auditState((string) $row['action']);
|
||||
$timestamp = strtotime((string) $row['created_at']);
|
||||
$out[] = array(
|
||||
'id' => (int) $row['id'],
|
||||
'source' => 'audit',
|
||||
'level' => $state,
|
||||
'time' => strtotime($row['created_at']),
|
||||
'time_display' => self::formatTime(strtotime($row['created_at'])),
|
||||
'time' => $timestamp,
|
||||
'time_display' => self::formatTime($timestamp),
|
||||
'date_group' => self::dateGroup($timestamp),
|
||||
'ip' => (string) $row['ip'],
|
||||
'actor' => $row['actor_name'] !== '' ? $row['actor_name'] : ('#' . (int) $row['actor_id']),
|
||||
'actor' => (string) $row['actor_name'] !== ''
|
||||
? (string) $row['actor_name']
|
||||
: '#' . (int) $row['actor_id'],
|
||||
'message' => AuditLog::actionLabel($row['action']),
|
||||
'url' => trim((string) $row['target_type'] . ($row['target_id'] ? ' #' . $row['target_id'] : '')),
|
||||
'details' => $meta,
|
||||
@@ -250,6 +254,24 @@
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function dateGroup($timestamp)
|
||||
{
|
||||
$timestamp = (int) $timestamp;
|
||||
if ($timestamp <= 0) {
|
||||
return 'Без даты';
|
||||
}
|
||||
|
||||
if (date('Y-m-d', $timestamp) === date('Y-m-d')) {
|
||||
return 'Сегодня';
|
||||
}
|
||||
|
||||
if (date('Y-m-d', $timestamp) === date('Y-m-d', strtotime('-1 day'))) {
|
||||
return 'Вчера';
|
||||
}
|
||||
|
||||
return date('d.m.Y', $timestamp);
|
||||
}
|
||||
|
||||
protected static function legacyRows($source, $limit)
|
||||
{
|
||||
$rows = self::readCsv($source, $limit);
|
||||
|
||||
@@ -169,7 +169,6 @@
|
||||
.events-hit-count {
|
||||
min-width: 40px;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.events-message {
|
||||
font-weight: 600;
|
||||
@@ -234,6 +233,19 @@
|
||||
border: none;
|
||||
min-height: 260px;
|
||||
}
|
||||
.events-table-day td {
|
||||
padding: 9px 14px;
|
||||
background: var(--background-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.events-table-day span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.events-table-wrap {
|
||||
max-height: none;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* JS раздела «Системные события»: раскрытие деталей и очистка legacy-логов.
|
||||
* JS раздела «Системные события»: раскрытие деталей и очистка журналов.
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
@@ -45,8 +45,10 @@
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'error',
|
||||
title: 'Очистить журнал?',
|
||||
message: 'Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке.',
|
||||
title: source === 'audit' ? 'Очистить аудит?' : 'Очистить журнал?',
|
||||
message: source === 'audit'
|
||||
? 'Все записи аудита будут удалены. Останется только служебная запись о самой очистке.'
|
||||
: 'Все записи выбранного журнала будут удалены. В аудите останется запись о самой очистке.',
|
||||
confirmLabel: 'Очистить',
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: function () {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<phrase data="auto_72aecd9ad85642d8">Error</phrase>
|
||||
<phrase data="auto_7c839c52bcdfbc1e">Clear log?</phrase>
|
||||
<phrase data="auto_98b2073ed1815f28">Clear</phrase>
|
||||
<phrase data="auto_a75cb7ce578a8ded">All entries in the selected journal will be deleted. The audit will only contain a record of the cleaning itself.</phrase>
|
||||
<phrase data="auto_c00aca49199296d7">Network error</phrase>
|
||||
<phrase data="runtime_bf22dfeeba8b8508">Error</phrase>
|
||||
</language>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<phrase data="auto_72aecd9ad85642d8">Ошибка</phrase>
|
||||
<phrase data="auto_7c839c52bcdfbc1e">Очистить журнал?</phrase>
|
||||
<phrase data="auto_98b2073ed1815f28">Очистить</phrase>
|
||||
<phrase data="auto_a75cb7ce578a8ded">Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке.</phrase>
|
||||
<phrase data="auto_c00aca49199296d7">Ошибка сети</phrase>
|
||||
<phrase data="runtime_bf22dfeeba8b8508">Ошибка</phrase>
|
||||
</language>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
{% if active_source == 'legacy' %}
|
||||
<div class="alert alert-info events-source-help"><i class="ti ti-info-circle alert-ic"></i><div><b>Что попадает в журнал приложения</b><p>Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся во вкладке «Аудит», SQL и 404 вынесены отдельно.</p></div></div>
|
||||
<div class="alert alert-info events-source-help"><i class="ti ti-info-circle alert-ic"></i><div><b>Что попадает в журнал приложения</b><p>Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся в «Аудите», SQL и 404 вынесены отдельно.</p></div></div>
|
||||
{% endif %}
|
||||
|
||||
<section class="tab-panel active">
|
||||
@@ -78,8 +78,8 @@
|
||||
<div class="events-section-title">
|
||||
<span class="icon-tile events-head-icon" style="--tile-bg:{{ active_source_def.tile_bg }};--tile-fg:{{ active_source_def.tile_fg }}"><i class="{{ active_source_def.icon }}"></i></span>
|
||||
<div>
|
||||
<h2>{{ active_source == 'referrers' ? 'Журнал переходов' : 'Список событий' }}</h2>
|
||||
<p class="text-secondary">{{ active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.' }}</p>
|
||||
<h2>{{ active_source == 'audit' ? 'Аудит действий' : (active_source == 'referrers' ? 'Журнал переходов' : 'Список событий') }}</h2>
|
||||
<p class="text-secondary">{{ active_source == 'audit' ? 'Кто, когда и что изменил в системе.' : (active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="events-section-meta">
|
||||
@@ -161,7 +161,14 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set active_day = '' %}
|
||||
{% for row in rows %}
|
||||
{% if active_source == 'audit' and active_day != row.date_group %}
|
||||
{% set active_day = row.date_group %}
|
||||
<tr class="events-table-day">
|
||||
<td colspan="6"><span>{{ active_day }}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="events-row {{ row.state_class }}">
|
||||
<td class="mono text-muted">{{ row.time_display }}</td>
|
||||
<td><span class="badge {{ row.state_badge }} events-state"><i class="{{ row.state_icon }}"></i>{{ row.state_label }}</span></td>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/GlobalSearch/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\GlobalSearch;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\Controller as BaseController;
|
||||
use App\Helpers\Request;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
public function index(array $params = array())
|
||||
{
|
||||
$query = trim(Request::getStr('q', ''));
|
||||
if (mb_strlen($query, 'UTF-8') < 2) {
|
||||
return $this->success('', array('data' => array('items' => array())));
|
||||
}
|
||||
|
||||
return $this->success('', array('data' => array('items' => Model::search($query, 30))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/GlobalSearch/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\GlobalSearch;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\DatabaseSchema;
|
||||
use App\Adminx\Support\GlobalSearchRegistry;
|
||||
use App\Adminx\Support\ModuleExtensions;
|
||||
use App\Content\ContentTables;
|
||||
use App\Content\Documents\DocumentSearch;
|
||||
use DB;
|
||||
|
||||
class Model
|
||||
{
|
||||
protected static $registered = false;
|
||||
|
||||
public static function search($query, $limit = 30)
|
||||
{
|
||||
self::registerProviders();
|
||||
ModuleExtensions::boot();
|
||||
return GlobalSearchRegistry::search($query, $limit);
|
||||
}
|
||||
|
||||
public static function documents($query, $limit = 8)
|
||||
{
|
||||
$table = ContentTables::table('documents');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$criteria = DocumentSearch::criteria($query, 'd');
|
||||
$sql = 'SELECT d.Id,d.document_title,d.document_alias,d.document_status'
|
||||
. ',(' . $criteria['score'] . ') AS search_relevance'
|
||||
. ' FROM ' . $table . ' d'
|
||||
. " WHERE d.document_deleted!='1' AND " . $criteria['where']
|
||||
. ' ORDER BY search_relevance DESC,d.document_status DESC,d.document_changed DESC'
|
||||
. ' LIMIT ' . max(1, min(12, (int) $limit));
|
||||
$rows = call_user_func_array(
|
||||
array('DB', 'query'),
|
||||
array_merge(array($sql), $criteria['score_args'], $criteria['where_args'])
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$item = self::item(
|
||||
'document',
|
||||
'Документы',
|
||||
self::decode($row['document_title']),
|
||||
'#' . (int) $row['Id'] . ' · /' . (string) $row['document_alias'],
|
||||
'/documents/' . (int) $row['Id'] . '/edit',
|
||||
'ti-file-text'
|
||||
);
|
||||
$item['score'] = (float) $row['search_relevance'];
|
||||
$out[] = $item;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function rubrics($query, $limit = 6)
|
||||
{
|
||||
$table = ContentTables::table('rubrics');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT Id,rubric_title,rubric_alias FROM ' . $table
|
||||
. ' WHERE rubric_title LIKE %ss OR rubric_alias LIKE %ss OR Id=%i ORDER BY rubric_title LIMIT ' . max(1, min(12, (int) $limit)),
|
||||
$query,
|
||||
$query,
|
||||
(int) $query
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::item('rubric', 'Рубрики', self::decode($row['rubric_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['rubric_alias'], '/rubrics?edit=' . (int) $row['Id'], 'ti-forms');
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function blocks($query, $limit = 6)
|
||||
{
|
||||
$table = ContentTables::table('sysblocks');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT id,sysblock_name,sysblock_alias FROM ' . $table
|
||||
. ' WHERE sysblock_name LIKE %ss OR sysblock_alias LIKE %ss OR id=%i ORDER BY sysblock_name LIMIT ' . max(1, min(12, (int) $limit)),
|
||||
$query,
|
||||
$query,
|
||||
(int) $query
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::item('block', 'Блоки', self::decode($row['sysblock_name']), '#' . (int) $row['id'] . ' · ' . (string) $row['sysblock_alias'], '/blocks?edit=' . (int) $row['id'], 'ti-blockquote');
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function requests($query, $limit = 6)
|
||||
{
|
||||
$table = ContentTables::table('request');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT Id,request_title,request_alias FROM ' . $table
|
||||
. ' WHERE request_title LIKE %ss OR request_alias LIKE %ss OR Id=%i ORDER BY request_title LIMIT ' . max(1, min(12, (int) $limit)),
|
||||
$query,
|
||||
$query,
|
||||
(int) $query
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::item('request', 'Запросы', self::decode($row['request_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['request_alias'], '/requests/' . (int) $row['Id'], 'ti-list-search');
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function navigation($query, $limit = 5)
|
||||
{
|
||||
$table = ContentTables::table('navigation_items');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT navigation_item_id,navigation_id,title,alias FROM ' . $table
|
||||
. ' WHERE title LIKE %ss OR alias LIKE %ss OR navigation_item_id=%i ORDER BY title LIMIT ' . max(1, min(12, (int) $limit)),
|
||||
$query,
|
||||
$query,
|
||||
(int) $query
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::item(
|
||||
'navigation',
|
||||
'Навигация',
|
||||
self::decode($row['title']),
|
||||
'#' . (int) $row['navigation_item_id'] . ' · ' . (string) $row['alias'],
|
||||
'/navigation?navigation=' . (int) $row['navigation_id'] . '&item=' . (int) $row['navigation_item_id'],
|
||||
'ti-menu-2'
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function resetRuntime()
|
||||
{
|
||||
self::$registered = false;
|
||||
GlobalSearchRegistry::resetRuntime();
|
||||
}
|
||||
|
||||
protected static function registerProviders()
|
||||
{
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registered = true;
|
||||
foreach (array(
|
||||
'documents' => array('provider' => array(self::class, 'documents'), 'permission' => 'view_documents', 'priority' => 10, 'limit' => 8),
|
||||
'rubrics' => array('provider' => array(self::class, 'rubrics'), 'permission' => 'view_rubrics', 'priority' => 20, 'limit' => 6),
|
||||
'blocks' => array('provider' => array(self::class, 'blocks'), 'permission' => 'view_blocks', 'priority' => 30, 'limit' => 6),
|
||||
'requests' => array('provider' => array(self::class, 'requests'), 'permission' => 'view_requests', 'priority' => 40, 'limit' => 6),
|
||||
'navigation' => array('provider' => array(self::class, 'navigation'), 'permission' => 'view_navigation', 'priority' => 50, 'limit' => 5),
|
||||
) as $code => $definition) {
|
||||
GlobalSearchRegistry::register('core.' . $code, $definition);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function item($type, $group, $title, $subtitle, $url, $icon)
|
||||
{
|
||||
return array(
|
||||
'type' => (string) $type,
|
||||
'group' => (string) $group,
|
||||
'title' => (string) $title,
|
||||
'subtitle' => (string) $subtitle,
|
||||
'url' => (string) $url,
|
||||
'icon' => (string) $icon,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function available($table)
|
||||
{
|
||||
try { return DatabaseSchema::tableExists($table); }
|
||||
catch (\Throwable $e) { return false; }
|
||||
}
|
||||
|
||||
protected static function decode($value)
|
||||
{
|
||||
return html_entity_decode(stripcslashes((string) $value), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/GlobalSearch/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' => 'global_search',
|
||||
'name' => 'Глобальный поиск',
|
||||
'version' => '0.2.0',
|
||||
'routes' => array(
|
||||
array('GET', '/search', array(\App\Adminx\GlobalSearch\Controller::class, 'index')),
|
||||
),
|
||||
);
|
||||
@@ -10,7 +10,7 @@
|
||||
<phrase data="auto_311e51e90722c580">Right</phrase>
|
||||
<phrase data="auto_12c7f4e4dc957223">Everything is right</phrase>
|
||||
<phrase data="auto_5b19b10f7cfa9da2">Rights</phrase>
|
||||
<phrase data="auto_f55f990ad542ffbd">Rights are not synchronized. Run</phrase>
|
||||
<phrase data="auto_f55f990ad542ffbd">Permissions are not synchronized. Update the schema in Database → Migrations.</phrase>
|
||||
<phrase data="auto_a5cd7a935114cdd8">Raleigh</phrase>
|
||||
<phrase data="auto_1ada279ae1fdd9f0">roles · assignment of access rights to sections</phrase>
|
||||
<phrase data="auto_afb1d0899bb631ca">There are no roles - run setup-rbac</phrase>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<phrase data="auto_311e51e90722c580">Прав</phrase>
|
||||
<phrase data="auto_12c7f4e4dc957223">Прав всего</phrase>
|
||||
<phrase data="auto_5b19b10f7cfa9da2">Права</phrase>
|
||||
<phrase data="auto_f55f990ad542ffbd">Права не синхронизированы. Запустите</phrase>
|
||||
<phrase data="auto_f55f990ad542ffbd">Права не синхронизированы. Обновите схему в разделе «База данных → Миграции».</phrase>
|
||||
<phrase data="auto_a5cd7a935114cdd8">Ролей</phrase>
|
||||
<phrase data="auto_1ada279ae1fdd9f0">ролей · назначение прав доступа по разделам</phrase>
|
||||
<phrase data="auto_afb1d0899bb631ca">Ролей нет — запустите setup-rbac</phrase>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
UPDATE `{{prefix}}_permissions`
|
||||
SET
|
||||
`name` = 'Доступ в панель управления',
|
||||
`description` = 'Разрешает вход в панель управления.'
|
||||
WHERE `code` = 'admin_panel';
|
||||
@@ -21,7 +21,7 @@
|
||||
return [
|
||||
'code' => 'groups',
|
||||
'name' => 'Роли и права',
|
||||
'version' => '0.1.0',
|
||||
'version' => '0.1.1',
|
||||
|
||||
'permissions' => [
|
||||
'key' => 'groups',
|
||||
@@ -89,6 +89,10 @@
|
||||
'id' => '005_register_development_site_permission',
|
||||
'file' => 'migrations/005_register_development_site_permission.sql',
|
||||
],
|
||||
[
|
||||
'id' => '006_normalize_control_panel_permission',
|
||||
'file' => 'migrations/006_normalize_control_panel_permission.sql',
|
||||
],
|
||||
],
|
||||
|
||||
'view_globals' => [
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted text-sm">Права не синхронизированы. Запустите <code>php adminx/tools/setup-rbac.php</code>.</div>
|
||||
<div class="text-muted text-sm">Права не синхронизированы. Обновите схему в разделе «База данных → Миграции».</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
$perPage = max(12, min(60, $perPage));
|
||||
|
||||
$listing = Model::listing($dir, $q, $listingType, $page, $perPage);
|
||||
$folderListing = Model::listing($dir, $q, '', 1, 1);
|
||||
|
||||
return $this->success('', array(
|
||||
'data' => array(
|
||||
@@ -96,7 +95,7 @@
|
||||
'type' => $type,
|
||||
'parent_dir' => Model::parentDir($listing['dir']),
|
||||
'breadcrumbs' => Model::breadcrumbs($listing['dir']),
|
||||
'folders' => $this->pickerFolders($folderListing['folders']),
|
||||
'folders' => $this->pickerFolders($listing['folders']),
|
||||
'files' => $this->pickerFiles($listing['files']),
|
||||
'page' => $listing['page'],
|
||||
'pages' => $listing['pages'],
|
||||
@@ -238,8 +237,8 @@
|
||||
return $this->error($e->getMessage(), array(), 422);
|
||||
}
|
||||
|
||||
$message = $result['files'] > 0
|
||||
? 'Удалено превью: ' . $result['files']
|
||||
$message = $result['roots'] > 0
|
||||
? 'Удалено папок превью: ' . $result['roots'] . ', файлов: ' . $result['files']
|
||||
: 'В этой папке превью не найдены';
|
||||
|
||||
return $this->success($message, array(
|
||||
@@ -352,6 +351,7 @@
|
||||
$out[] = array(
|
||||
'name' => isset($folder['name']) ? $folder['name'] : '',
|
||||
'path' => isset($folder['path']) ? $folder['path'] : '',
|
||||
'parent' => isset($folder['parent']) ? $folder['parent'] : '',
|
||||
'count' => isset($folder['count']) ? (int) $folder['count'] : 0,
|
||||
);
|
||||
}
|
||||
|
||||
+101
-30
@@ -124,28 +124,29 @@
|
||||
|
||||
$folders = array();
|
||||
$files = array();
|
||||
$items = @scandir($abs);
|
||||
if ($items === false) {
|
||||
$items = array();
|
||||
}
|
||||
|
||||
foreach ($items as $name) {
|
||||
if (!self::isVisibleEntry($name)) {
|
||||
continue;
|
||||
if ($q !== '') {
|
||||
self::searchTree($dir, $abs, $q, $type, $folders, $files);
|
||||
} else {
|
||||
$items = @scandir($abs);
|
||||
if ($items === false) {
|
||||
$items = array();
|
||||
}
|
||||
|
||||
$path = rtrim($dir, '/') . '/' . $name;
|
||||
$itemAbs = $abs . DIRECTORY_SEPARATOR . $name;
|
||||
if (is_dir($itemAbs)) {
|
||||
if (self::matches($name, $q, $type, true)) {
|
||||
$folders[] = self::folderRow($path, $itemAbs);
|
||||
foreach ($items as $name) {
|
||||
if (!self::isVisibleEntry($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$path = rtrim($dir, '/') . '/' . $name;
|
||||
$itemAbs = $abs . DIRECTORY_SEPARATOR . $name;
|
||||
if (is_dir($itemAbs)) {
|
||||
$folders[] = self::folderRow($path, $itemAbs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_file($itemAbs) && self::matches($name, $q, $type, false)) {
|
||||
$files[] = self::fileRow($path, $itemAbs);
|
||||
if (is_file($itemAbs) && self::matches($name, '', $type, false)) {
|
||||
$files[] = self::fileRow($path, $itemAbs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,25 +423,60 @@
|
||||
throw new \RuntimeException('Папка не найдена');
|
||||
}
|
||||
|
||||
$thumbDir = self::thumbnailDirName();
|
||||
$target = rtrim($abs, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $thumbDir;
|
||||
if (!is_dir($target)) {
|
||||
return array('files' => 0, 'dirs' => 0, 'path' => rtrim($dir, '/') . '/' . $thumbDir);
|
||||
}
|
||||
|
||||
$base = realpath($abs);
|
||||
$real = realpath($target);
|
||||
if (!$base || !$real || basename($real) !== $thumbDir || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) {
|
||||
throw new \RuntimeException('Некорректная папка превью');
|
||||
if (!$base) {
|
||||
throw new \RuntimeException('Некорректная папка');
|
||||
}
|
||||
|
||||
$thumbDir = self::thumbnailDirName();
|
||||
$targets = array();
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iterator as $item) {
|
||||
if (!$item->isDir() || $item->isLink() || $item->getFilename() !== $thumbDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$real = $item->getRealPath();
|
||||
if ($real && strpos($real, $base . DIRECTORY_SEPARATOR) === 0) {
|
||||
$targets[$real] = $real;
|
||||
}
|
||||
}
|
||||
|
||||
$direct = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $thumbDir;
|
||||
if (is_dir($direct)) {
|
||||
$real = realpath($direct);
|
||||
if ($real && strpos($real, $base . DIRECTORY_SEPARATOR) === 0) {
|
||||
$targets[$real] = $real;
|
||||
}
|
||||
}
|
||||
|
||||
uksort($targets, function ($left, $right) {
|
||||
return strlen($right) - strlen($left);
|
||||
});
|
||||
$files = 0;
|
||||
$dirs = 0;
|
||||
if (!self::removeDirWithCount($real, $files, $dirs)) {
|
||||
throw new \RuntimeException('Не удалось удалить превью');
|
||||
$roots = 0;
|
||||
foreach ($targets as $target) {
|
||||
if (!is_dir($target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!self::removeDirWithCount($target, $files, $dirs)) {
|
||||
throw new \RuntimeException('Не удалось удалить превью');
|
||||
}
|
||||
|
||||
$roots++;
|
||||
}
|
||||
|
||||
return array('files' => $files, 'dirs' => $dirs, 'path' => rtrim($dir, '/') . '/' . $thumbDir);
|
||||
return array(
|
||||
'files' => $files,
|
||||
'dirs' => $dirs,
|
||||
'roots' => $roots,
|
||||
'path' => $dir,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -708,7 +744,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
return array('name' => basename($path), 'path' => $path, 'count' => $count);
|
||||
return array(
|
||||
'name' => basename($path),
|
||||
'path' => $path,
|
||||
'parent' => self::parentDir($path),
|
||||
'count' => $count,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function searchTree($dir, $abs, $q, $type, array &$folders, array &$files)
|
||||
{
|
||||
$rootLength = strlen(rtrim($abs, DIRECTORY_SEPARATOR));
|
||||
$directory = new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS);
|
||||
$visible = new \RecursiveCallbackFilterIterator($directory, function ($item) {
|
||||
return !$item->isLink() && self::isVisibleEntry($item->getFilename());
|
||||
});
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
$visible,
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
$name = $item->getFilename();
|
||||
$relative = ltrim(str_replace('\\', '/', substr($item->getPathname(), $rootLength)), '/');
|
||||
$path = rtrim($dir, '/') . '/' . $relative;
|
||||
if ($item->isDir()) {
|
||||
if (self::matches($name, $q, '', true)) {
|
||||
$folders[] = self::folderRow($path, $item->getPathname());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->isFile() && self::matches($name, $q, $type, false)) {
|
||||
$files[] = self::fileRow($path, $item->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function stats($absRoot)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-stat span {
|
||||
font-size: 13px;
|
||||
@@ -271,15 +270,15 @@ button.media-thumb {
|
||||
font: inherit;
|
||||
}
|
||||
.media-tile.is-folder .media-thumb {
|
||||
background: color-mix(in srgb, #f59e0b 14%, var(--background-subtle));
|
||||
color: #d97706;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--background-subtle));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.media-tile.is-folder .media-thumb i {
|
||||
font-size: 40px;
|
||||
}
|
||||
.media-tile.is-folder:hover .media-thumb {
|
||||
background: color-mix(in srgb, #f59e0b 22%, var(--background-subtle));
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, var(--color-primary) 20%, var(--background-subtle));
|
||||
color: var(--blue-700);
|
||||
}
|
||||
.media-name,
|
||||
.media-tile b {
|
||||
@@ -378,6 +377,16 @@ button.media-thumb {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-folder-path {
|
||||
display: block;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-row-icon,
|
||||
.media-row-preview {
|
||||
width: 38px;
|
||||
@@ -401,7 +410,7 @@ button.media-thumb {
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.media-row-icon.is-folder {
|
||||
color: #f59e0b;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.media-row-actions {
|
||||
justify-content: flex-end;
|
||||
@@ -473,7 +482,6 @@ button.media-thumb {
|
||||
color: #fff;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -567,7 +575,6 @@ button.media-thumb {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-result-stage {
|
||||
display: grid;
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
var name = button.getAttribute('data-name') || button.getAttribute('data-path') || '';
|
||||
Adminx.Confirm.open({
|
||||
kind: 'warning',
|
||||
title: 'Удалить превью в папке?',
|
||||
message: 'Будет удалена только служебная папка превью для «' + name + '». Исходные файлы останутся на месте.',
|
||||
title: 'Удалить все превью?',
|
||||
message: 'Во всех вложенных папках «' + name + '» будут удалены только служебные каталоги превью. Исходные файлы останутся на месте.',
|
||||
confirmLabel: 'Удалить превью',
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: function () {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{% if can_manage %}
|
||||
<div class="cluster">
|
||||
<button class="btn btn-secondary" type="button" data-media-folder><i class="ti ti-folder-plus"></i>Папка</button>
|
||||
<button class="btn btn-danger-soft" type="button" data-media-clear-thumbs data-path="{{ dir }}" data-name="{{ dir }}" data-tooltip="Удалить все превью в текущей папке"><i class="ti ti-photo-off"></i>Очистить превью</button>
|
||||
<button class="btn btn-danger-soft" type="button" data-media-clear-thumbs data-path="{{ dir }}" data-name="{{ dir }}" data-tooltip="Удалить превью в текущей и вложенных папках"><i class="ti ti-photo-off"></i>Очистить превью</button>
|
||||
<button class="btn btn-primary" type="button" data-media-upload-trigger><i class="ti ti-upload"></i>Загрузить</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -33,7 +33,7 @@
|
||||
<div><b>{{ stats.images }}</b><span>Изображений</span></div>
|
||||
</div>
|
||||
<div class="card media-stat">
|
||||
<span class="icon-tile" style="--tile-bg:#fff7ed;--tile-fg:#ea580c"><i class="ti ti-folders"></i></span>
|
||||
<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-folders"></i></span>
|
||||
<div><b>{{ stats.folders }}</b><span>Папок</span></div>
|
||||
</div>
|
||||
<div class="card media-stat">
|
||||
@@ -131,10 +131,10 @@
|
||||
<article class="media-tile is-folder">
|
||||
<a class="media-thumb" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=grid&q={{ filters.q|url_encode }}&type={{ filters.type }}"><i class="ti ti-folder"></i></a>
|
||||
<a class="media-name" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=grid&q={{ filters.q|url_encode }}&type={{ filters.type }}">{{ folder.name }}</a>
|
||||
<small>{{ folder.count }} объектов</small>
|
||||
<small>{% if filters.q %}{{ folder.path }} · {% endif %}{{ folder.count }} объектов</small>
|
||||
{% if can_manage %}
|
||||
<div class="media-actions">
|
||||
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью во вложенных папках" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
|
||||
</div>
|
||||
@@ -179,8 +179,8 @@
|
||||
{% for folder in folders %}
|
||||
<tr>
|
||||
<td><a class="media-row-name" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=table&q={{ filters.q|url_encode }}&type={{ filters.type }}"><span class="media-row-icon is-folder"><i class="ti ti-folder"></i></span><b>{{ folder.name }}</b></a></td>
|
||||
<td>Папка</td><td>{{ folder.count }} объектов</td><td>-</td>
|
||||
<td>{% if can_manage %}<div class="cluster media-row-actions"><button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}</td>
|
||||
<td>Папка</td><td>{% if filters.q %}<span class="media-folder-path">{{ folder.path }}</span>{% else %}{{ folder.count }} объектов{% endif %}</td><td>-</td>
|
||||
<td>{% if can_manage %}<div class="cluster media-row-actions"><button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью во вложенных папках" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for f in files %}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
'repository' => $repository,
|
||||
'repository_settings' => ModuleRepository::settings(),
|
||||
'official_repository' => ModuleRepository::officialSettings(),
|
||||
'focus_code' => strtolower(trim(Request::getStr('focus', ''))),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -140,10 +140,9 @@
|
||||
}
|
||||
|
||||
$migrationPending = !empty($migrationState['pending']);
|
||||
$needsUpdate = $installed && (
|
||||
($fileVersion !== '' && $fileVersion !== $dbVersion)
|
||||
|| $migrationPending
|
||||
);
|
||||
// The lifecycle version records the package that completed installation.
|
||||
// A source version bump alone does not require a database operation.
|
||||
$needsUpdate = $installed && $migrationPending;
|
||||
return array(
|
||||
'id' => 0,
|
||||
'code' => $extension['code'],
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
.modules-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.modules-table tr.is-focused > td,
|
||||
.modules-repository-card tr.is-focused > td {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
.modules-identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -146,9 +150,6 @@
|
||||
color: var(--color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
.modules-version {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.modules-version > * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
var state = document.querySelector('[data-module-state]');
|
||||
var sort = document.querySelector('[data-module-sort]');
|
||||
var repositorySort = document.querySelector('[data-repository-sort]');
|
||||
this.focusRequestedModule(page, search, state);
|
||||
if (search) { search.addEventListener('input', function () { self.filter(); }); }
|
||||
if (state) { state.addEventListener('change', function () { self.filter(); }); }
|
||||
if (sort) { sort.addEventListener('change', function () { self.sortRows('[data-module-row]', sort.value); }); }
|
||||
@@ -50,6 +51,26 @@
|
||||
this.initArchive();
|
||||
},
|
||||
|
||||
focusRequestedModule: function (page, search, state) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var code = (params.get('focus') || '').trim().toLowerCase();
|
||||
if (!code) { return; }
|
||||
|
||||
if (params.get('tab') !== 'catalog' && search) {
|
||||
search.value = code;
|
||||
if (state) { state.value = 'all'; }
|
||||
this.filter();
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
var selector = params.get('tab') === 'catalog' ? '[data-repository-row]' : '[data-module-row]';
|
||||
var row = page.querySelector(selector + '[data-code="' + CSS.escape(code) + '"]');
|
||||
if (!row) { return; }
|
||||
row.classList.add('is-focused');
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 80);
|
||||
},
|
||||
|
||||
base: function () { return Adminx.base(); },
|
||||
|
||||
filter: function () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="auto_093a3bc2fa9b4629">Update module</phrase>
|
||||
<phrase data="auto_0a5577462b8be085">The module, its data, settings, menus and rights will be deleted. This action is irreversible.</phrase>
|
||||
<phrase data="auto_0aca097f8e8b02be">Restore module operation?</phrase>
|
||||
<phrase data="auto_0bff2559ed01357f">Successful migrations will be skipped and the aborted step will be re-executed. Please check the given error before proceeding.</phrase>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="auto_093a3bc2fa9b4629">Обновить модуль</phrase>
|
||||
<phrase data="auto_0a5577462b8be085">Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо.</phrase>
|
||||
<phrase data="auto_0aca097f8e8b02be">Восстановить операцию модуля?</phrase>
|
||||
<phrase data="auto_0bff2559ed01357f">Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку.</phrase>
|
||||
|
||||
@@ -80,13 +80,11 @@
|
||||
'code' => 'modules_registry',
|
||||
'label' => 'Управление',
|
||||
'url' => '/modules/',
|
||||
'exact' => true,
|
||||
'permission' => 'view_modules',
|
||||
'group' => 'Система',
|
||||
'parent' => 'modules',
|
||||
'sort_order' => 1,
|
||||
'match' => array(
|
||||
'/modules',
|
||||
),
|
||||
),
|
||||
),
|
||||
'routes' => array(
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<tbody>
|
||||
{% for module in modules %}
|
||||
{% set state = module.error ? 'error' : (module.recoverable ? 'recovery' : (not module.installed ? 'available' : (module.needs_update ? 'update' : (module.enabled ? 'active' : 'inactive')))) %}
|
||||
<tr class="modules-scope-{{ module.scope }}" data-module-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}" data-state="{{ state }}" data-scope="{{ module.scope }}" data-search="{{ (module.name ~ ' ' ~ module.code ~ ' ' ~ module.function ~ ' ' ~ module.scope)|lower }}">
|
||||
<tr class="modules-scope-{{ module.scope }}{{ focus_code == module.code ? ' is-focused' : '' }}" data-module-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}" data-state="{{ state }}" data-scope="{{ module.scope }}" data-search="{{ (module.name ~ ' ' ~ module.code ~ ' ' ~ module.function ~ ' ' ~ module.scope)|lower }}">
|
||||
<td>
|
||||
<div class="modules-identity">
|
||||
<span class="modules-icon"><i class="{{ module.icon|default('ti ti-puzzle') }}"></i></span>
|
||||
@@ -97,7 +97,7 @@
|
||||
{% if module.error %}<span class="badge badge-red badge-dot">Ошибка</span>
|
||||
{% elseif module.recoverable %}<span class="badge badge-red badge-dot">Восстановление</span>
|
||||
{% elseif not module.installed %}<span class="badge badge-gray badge-dot">Не установлен</span>
|
||||
{% elseif module.needs_update %}<span class="badge badge-orange badge-dot">{{ module.migration_pending ? 'Миграции' : 'Обновление' }}</span>
|
||||
{% elseif module.needs_update %}<span class="badge badge-orange badge-dot">Миграции</span>
|
||||
{% elseif module.enabled %}<span class="badge badge-green badge-dot">Активен</span>
|
||||
{% else %}<span class="badge badge-gray badge-dot">Отключён</span>{% endif %}
|
||||
</td>
|
||||
@@ -108,7 +108,7 @@
|
||||
</td>
|
||||
<td class="modules-version">
|
||||
<b class="mono" data-tooltip="Версия файлов">Файлы: {{ module.file_version ?: '—' }}</b>
|
||||
{% if module.installed and module.db_version != module.file_version %}<span class="text-muted text-xs mono">БД: {{ module.db_version ?: '—' }}</span>{% endif %}
|
||||
{% if module.migration_pending %}<span class="text-muted text-xs mono">Установлено: {{ module.db_version ?: '—' }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if module.admin_url or can_manage %}
|
||||
@@ -127,7 +127,7 @@
|
||||
{% if module.removable %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-module-action="remove" data-tooltip="Удалить файлы" aria-label="Удалить файлы"><i class="ti ti-folder-x"></i></button>{% endif %}
|
||||
{% elseif module.installed %}
|
||||
<label class="switch modules-switch" data-tooltip="{{ module.enabled ? 'Отключить' : 'Включить' }}"><input type="checkbox" data-module-toggle{{ module.enabled ? ' checked' : '' }}><span></span></label>
|
||||
{% if module.needs_update %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-ok" type="button" data-module-action="update" data-tooltip="{{ module.migration_pending ? 'Применить миграции' : 'Обновить модуль' }}" aria-label="{{ module.migration_pending ? 'Применить миграции' : 'Обновить модуль' }}"><i class="ti {{ module.migration_pending ? 'ti-database-import' : 'ti-refresh' }}"></i></button>{% endif %}
|
||||
{% if module.needs_update %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-ok" type="button" data-module-action="update" data-tooltip="Применить миграции" aria-label="Применить миграции"><i class="ti ti-database-import"></i></button>{% endif %}
|
||||
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-view" type="button" data-module-action="reinstall" data-tooltip="Переустановить" aria-label="Переустановить"><i class="ti ti-reload"></i></button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-module-action="uninstall" data-tooltip="Деинсталлировать" aria-label="Деинсталлировать"><i class="ti ti-trash"></i></button>
|
||||
{% endif %}
|
||||
@@ -191,7 +191,7 @@
|
||||
<thead><tr><th>Модуль</th><th style="width:154px">Область</th><th style="width:170px">Версия</th><th style="width:150px">Состояние</th><th style="width:76px"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for module in repository.items %}
|
||||
<tr data-repository-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}">
|
||||
<tr class="{{ focus_code == module.code ? 'is-focused' : '' }}" data-repository-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}">
|
||||
<td><div class="modules-identity"><span class="modules-icon"><i class="ti ti-package"></i></span><span class="modules-copy"><span class="modules-title">{{ module.name }}</span><span class="modules-meta"><span class="mono">{{ module.code }}</span>{% if module.author %}<span>{{ module.author }}</span>{% endif %}</span>{% if module.description %}<span class="modules-description">{{ module.description }}</span>{% endif %}</span></div></td>
|
||||
<td>{% if module.kind == 'admin' %}<span class="badge modules-scope-badge is-admin"><i class="ti ti-settings"></i>Админка</span>{% else %}<span class="badge modules-scope-badge is-mixed"><i class="ti ti-arrows-exchange"></i>Паблик + админка</span>{% endif %}</td>
|
||||
<td class="modules-version"><b class="mono">Каталог: {{ module.version }}</b>{% if module.local_version %}<span class="text-muted text-xs mono">Файлы: {{ module.local_version }}</span>{% endif %}{% if module.migration_pending %}<span class="text-muted text-xs mono">БД: {{ module.database_version ?: '—' }}</span>{% endif %}</td>
|
||||
|
||||
@@ -139,35 +139,7 @@
|
||||
|
||||
public static function documentPicker($q = '', $limit = 30)
|
||||
{
|
||||
$limit = max(5, min(50, (int) $limit));
|
||||
$sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, d.document_status, r.rubric_title'
|
||||
. ' FROM ' . ContentTables::table('documents') . ' d'
|
||||
. ' LEFT JOIN ' . ContentTables::table('rubrics') . ' 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;
|
||||
}
|
||||
|
||||
$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(isset($row['document_title']) ? $row['document_title'] : ''),
|
||||
'alias' => (string) $row['document_alias'],
|
||||
'status' => (int) $row['document_status'],
|
||||
'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''),
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
return (new \App\Content\Documents\DocumentPickerRepository())->search($q, array(), $limit);
|
||||
}
|
||||
|
||||
public static function stats()
|
||||
@@ -388,6 +360,16 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
$dependencies = \App\Content\ContentTagDependencies::navigation(
|
||||
$id,
|
||||
isset($row['alias']) ? (string) $row['alias'] : ''
|
||||
);
|
||||
if ($dependencies) {
|
||||
throw new \RuntimeException(
|
||||
'Навигация используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти связи.'
|
||||
);
|
||||
}
|
||||
|
||||
self::clearCache($id, isset($row['alias']) ? $row['alias'] : '');
|
||||
DB::Delete(self::itemsTable(), 'navigation_id = %i', $id);
|
||||
DB::Delete(self::table(), 'navigation_id = %i', $id);
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.navigation-stat span {
|
||||
font-size: 13px;
|
||||
@@ -455,7 +454,6 @@
|
||||
background: var(--background-surface);
|
||||
font-size: 10.5px;
|
||||
font-style: normal;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.navigation-template-tab:hover {
|
||||
color: var(--text-primary);
|
||||
@@ -558,7 +556,6 @@
|
||||
background: var(--blue-100);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.navigation-tags-button {
|
||||
color: var(--cyan-600);
|
||||
@@ -638,7 +635,6 @@
|
||||
.navigation-tag-nav b {
|
||||
color: var(--text-muted);
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.navigation-tag-nav button.is-active b {
|
||||
color: var(--color-primary);
|
||||
@@ -1035,7 +1031,6 @@
|
||||
.navigation-picker-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.navigation-document-main {
|
||||
display: grid;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
currentNavigationId: 0,
|
||||
currentNavigationFlat: [],
|
||||
currentItemId: 0,
|
||||
pendingItemId: 0,
|
||||
dragItem: null,
|
||||
dragGroup: [],
|
||||
dragPlaceholder: null,
|
||||
@@ -97,6 +98,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
this.openDeepLink();
|
||||
|
||||
document.addEventListener('submit', function (e) {
|
||||
var filter = e.target.closest('.navigation-filter');
|
||||
if (!filter) { return; }
|
||||
@@ -567,6 +570,18 @@
|
||||
this.loadItems();
|
||||
},
|
||||
|
||||
openDeepLink: function () {
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
var navigationId = parseInt(params.get('navigation'), 10) || 0;
|
||||
var itemId = parseInt(params.get('item'), 10) || 0;
|
||||
if (!navigationId) { return; }
|
||||
var row = document.querySelector('[data-navigation-row][data-id="' + navigationId + '"]');
|
||||
if (!row) { return; }
|
||||
this.pendingItemId = itemId;
|
||||
this.openItems(row);
|
||||
if (Adminx.Drawer) { Adminx.Drawer.open('navigationItemsDrawer'); }
|
||||
},
|
||||
|
||||
loadItems: function () {
|
||||
var self = this;
|
||||
if (!this.currentNavigationId) { return; }
|
||||
@@ -584,6 +599,11 @@
|
||||
if (subtitle) { subtitle.textContent = data.navigation ? data.navigation.tag : 'Структура меню'; }
|
||||
if (count) { count.textContent = String(self.currentNavigationFlat.length); }
|
||||
self.renderItems(data.items || []);
|
||||
if (self.pendingItemId) {
|
||||
var pendingItemId = self.pendingItemId;
|
||||
self.pendingItemId = 0;
|
||||
self.fillItemEdit(pendingItemId);
|
||||
}
|
||||
if (self.currentItemId) {
|
||||
self.markSelectedItem(self.currentItemId);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
'icon' => 'ti ti-sitemap',
|
||||
'permission' => 'view_navigation',
|
||||
'group' => 'Контент',
|
||||
'sort_order' => 25,
|
||||
'sort_order' => 26,
|
||||
'match' => array(
|
||||
'/navigation',
|
||||
),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/PublicSite/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\PublicSite;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\AdminAssets;
|
||||
use App\Common\Controller as BaseController;
|
||||
use App\Common\Permission;
|
||||
use App\Content\Introspection\IntrospectionService;
|
||||
use App\Helpers\Json;
|
||||
use App\Helpers\Request;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
public function index(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) {
|
||||
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
|
||||
}
|
||||
|
||||
AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50);
|
||||
$query = Request::getStr('q', '');
|
||||
$structure = Model::structure($query);
|
||||
|
||||
return $this->render('@public_site/index.twig', array(
|
||||
'stats' => Model::stats(),
|
||||
'structure' => $structure,
|
||||
'diagnostics' => Model::diagnostics($structure),
|
||||
'filters' => array('q' => $query),
|
||||
));
|
||||
}
|
||||
|
||||
public function placements(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) {
|
||||
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
|
||||
}
|
||||
|
||||
AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50);
|
||||
$filters = array(
|
||||
'q' => Request::getStr('q', ''),
|
||||
'type' => Request::getStr('type', ''),
|
||||
'state' => Request::getStr('state', ''),
|
||||
'area' => Request::getStr('area', ''),
|
||||
'view' => Request::getStr('view', 'placements') === 'usage' ? 'usage' : 'placements',
|
||||
'page' => max(1, Request::getInt('page', 1)),
|
||||
);
|
||||
|
||||
return $this->render('@public_site/placements.twig', array(
|
||||
'placement_map' => Model::placements($filters),
|
||||
'filters' => $filters,
|
||||
'placement_view' => $filters['view'],
|
||||
'rebuilt' => Request::getInt('rebuilt', 0) === 1,
|
||||
));
|
||||
}
|
||||
|
||||
public function siteMap(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) {
|
||||
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
|
||||
}
|
||||
|
||||
AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50);
|
||||
return $this->render('@public_site/map.twig', array(
|
||||
'site_map' => Model::siteMap(Request::getStr('q', '')),
|
||||
'filters' => array('q' => Request::getStr('q', '')),
|
||||
));
|
||||
}
|
||||
|
||||
public function diagnostics(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) {
|
||||
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
|
||||
}
|
||||
|
||||
AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50);
|
||||
$target = trim(Request::getStr('target', ''));
|
||||
$requestId = max(0, Request::getInt('request_id', 0));
|
||||
$documentId = max(0, Request::getInt('document_id', 0));
|
||||
$parametersInput = trim(Request::getStr('parameters', ''));
|
||||
$page = null;
|
||||
$request = null;
|
||||
$pageError = '';
|
||||
$requestError = '';
|
||||
$service = new IntrospectionService();
|
||||
|
||||
if ($target !== '') {
|
||||
try {
|
||||
$page = $service->page($target);
|
||||
} catch (\Throwable $e) {
|
||||
$pageError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($requestId > 0 || $documentId > 0 || $parametersInput !== '') {
|
||||
try {
|
||||
if ($requestId <= 0 || $documentId <= 0) {
|
||||
throw new \InvalidArgumentException('Укажите ID запроса и документа');
|
||||
}
|
||||
|
||||
$request = $service->request($requestId, $documentId, $this->diagnosticParameters($parametersInput));
|
||||
} catch (\Throwable $e) {
|
||||
$requestError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('@public_site/diagnostics.twig', array(
|
||||
'target' => $target,
|
||||
'page_context' => $page,
|
||||
'page_error' => $pageError,
|
||||
'page_json' => $page ? Json::encode($page, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) : '',
|
||||
'request_id' => $requestId,
|
||||
'document_id' => $documentId,
|
||||
'parameters_input' => $parametersInput,
|
||||
'request_explanation' => $request,
|
||||
'request_error' => $requestError,
|
||||
'request_json' => $request ? Json::encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) : '',
|
||||
));
|
||||
}
|
||||
|
||||
public function rebuildPlacements(array $params = array())
|
||||
{
|
||||
if (($error = $this->guardPermission('view_public_site')) !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
Model::placements(array(), true);
|
||||
$this->redirect($this->base() . '/public-site/placements?rebuilt=1');
|
||||
}
|
||||
|
||||
protected function diagnosticParameters($input)
|
||||
{
|
||||
$input = trim((string) $input);
|
||||
if ($input === '') {
|
||||
return array();
|
||||
}
|
||||
|
||||
if (substr($input, 0, 1) === '{') {
|
||||
$decoded = Json::decode($input);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \InvalidArgumentException('JSON параметров должен содержать объект');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
$result = array();
|
||||
parse_str($input, $result);
|
||||
return is_array($result) ? $result : array();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/PublicSite/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\PublicSite;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\DatabaseSchema;
|
||||
use App\Content\CatalogTables;
|
||||
use App\Content\ContentPlacementMap;
|
||||
use App\Content\ContentTables;
|
||||
use DB;
|
||||
|
||||
class Model
|
||||
{
|
||||
public static function stats()
|
||||
{
|
||||
return array(
|
||||
'rubrics' => self::count(ContentTables::table('rubrics')),
|
||||
'documents' => self::count(ContentTables::table('documents'), "document_deleted!='1'"),
|
||||
'requests' => self::count(ContentTables::table('request')),
|
||||
'components' => self::count(ContentTables::table('sysblocks'))
|
||||
+ self::count(ContentTables::table('navigation')),
|
||||
'catalogs' => self::count(CatalogTables::table('module_catalog_settings')),
|
||||
'presentations' => self::count(CatalogTables::table('catalog_card_templates'))
|
||||
+ self::count(CatalogTables::table('catalog_filter_templates')),
|
||||
);
|
||||
}
|
||||
|
||||
public static function structure($query = '')
|
||||
{
|
||||
$query = trim((string) $query);
|
||||
$sql = 'SELECT r.Id id,r.rubric_title title,r.rubric_alias alias,r.rubric_template_id template_id,'
|
||||
. ' r.rubric_docs_active documents_enabled,CHAR_LENGTH(TRIM(r.rubric_template)) template_length,'
|
||||
. ' COALESCE(t.template_title,\'\') template_title'
|
||||
. ' FROM ' . ContentTables::table('rubrics') . ' r'
|
||||
. ' LEFT JOIN ' . ContentTables::table('templates') . ' t ON t.Id=r.rubric_template_id';
|
||||
$args = array();
|
||||
if ($query !== '') {
|
||||
$sql .= ' WHERE r.rubric_title LIKE %s OR r.rubric_alias LIKE %s OR r.Id=%i';
|
||||
$args[] = '%' . $query . '%';
|
||||
$args[] = '%' . $query . '%';
|
||||
$args[] = (int) $query;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY r.rubric_position,r.rubric_title,r.Id';
|
||||
$rows = self::queryAll($sql, $args);
|
||||
$documents = self::documentCounts();
|
||||
$fields = self::groupedCount(ContentTables::table('rubric_fields'), 'rubric_id');
|
||||
$requests = self::groupedCount(ContentTables::table('request'), 'rubric_id');
|
||||
$rubricTemplates = self::groupedCount(ContentTables::table('rubric_templates'), 'rubric_id');
|
||||
$catalogs = self::groupedCount(CatalogTables::table('module_catalog_settings'), 'rubric_id');
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$id = (int) $row['id'];
|
||||
$row['id'] = $id;
|
||||
$row['title'] = self::decode($row['title']);
|
||||
$row['alias'] = (string) $row['alias'];
|
||||
$row['template_id'] = (int) $row['template_id'];
|
||||
$row['template_title'] = self::decode($row['template_title']);
|
||||
$row['documents_enabled'] = (int) $row['documents_enabled'] === 1;
|
||||
$row['documents_count'] = isset($documents[$id]) ? (int) $documents[$id]['total'] : 0;
|
||||
$row['published_count'] = isset($documents[$id]) ? (int) $documents[$id]['published'] : 0;
|
||||
$row['fields_count'] = isset($fields[$id]) ? (int) $fields[$id] : 0;
|
||||
$row['requests_count'] = isset($requests[$id]) ? (int) $requests[$id] : 0;
|
||||
$row['rubric_templates_count'] = isset($rubricTemplates[$id]) ? (int) $rubricTemplates[$id] : 0;
|
||||
$row['catalogs_count'] = isset($catalogs[$id]) ? (int) $catalogs[$id] : 0;
|
||||
$row['issues'] = self::rubricIssues($row);
|
||||
}
|
||||
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function diagnostics(array $structure)
|
||||
{
|
||||
$items = array();
|
||||
foreach ($structure as $rubric) {
|
||||
foreach ($rubric['issues'] as $issue) {
|
||||
$items[] = array(
|
||||
'level' => $issue['level'],
|
||||
'title' => $rubric['title'],
|
||||
'message' => $issue['message'],
|
||||
'url' => '/rubrics?q=' . rawurlencode($rubric['title']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$requestTable = ContentTables::table('request');
|
||||
if (self::available($requestTable)) {
|
||||
$rows = DB::query(
|
||||
'SELECT Id,request_title,request_alias,request_template_item FROM ' . $requestTable
|
||||
. " WHERE TRIM(request_template_item)=''"
|
||||
. ' ORDER BY request_title,Id LIMIT 50'
|
||||
)->getAll() ?: array();
|
||||
foreach ($rows as $row) {
|
||||
$items[] = array(
|
||||
'level' => 'warning',
|
||||
'title' => self::decode($row['request_title']),
|
||||
'message' => 'У подборки не заполнен шаблон элемента',
|
||||
'url' => '/requests',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'items' => $items,
|
||||
'errors' => count(array_filter($items, function ($item) { return $item['level'] === 'error'; })),
|
||||
'warnings' => count(array_filter($items, function ($item) { return $item['level'] === 'warning'; })),
|
||||
);
|
||||
}
|
||||
|
||||
public static function placements(array $filters = array(), $refresh = false)
|
||||
{
|
||||
$all = ContentPlacementMap::all((bool) $refresh);
|
||||
$query = self::lower(isset($filters['q']) ? trim((string) $filters['q']) : '');
|
||||
$type = isset($filters['type']) ? trim((string) $filters['type']) : '';
|
||||
$state = isset($filters['state']) ? trim((string) $filters['state']) : '';
|
||||
$area = isset($filters['area']) ? trim((string) $filters['area']) : '';
|
||||
$view = isset($filters['view']) && $filters['view'] === 'usage' ? 'usage' : 'placements';
|
||||
$page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1;
|
||||
$rows = array_values(array_filter($all, function ($item) use ($query, $type, $state, $area) {
|
||||
if ($type !== '' && $item['component_type'] !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($state !== '' && $item['component_status'] !== $state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($area !== '' && $item['area'] !== $area) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = self::lower(implode(' ', array(
|
||||
$item['source_title'],
|
||||
$item['source_type_title'],
|
||||
$item['source_part_title'],
|
||||
$item['component_title'],
|
||||
$item['component_alias'],
|
||||
$item['component_key'],
|
||||
$item['tag'],
|
||||
)));
|
||||
return strpos($haystack, $query) !== false;
|
||||
}));
|
||||
$filteredCount = count($rows);
|
||||
$usage = self::placementUsage($rows);
|
||||
$pagination = self::paginate($view === 'usage' ? $usage : $rows, $page, 25);
|
||||
if ($view === 'usage') {
|
||||
$usage = $pagination['items'];
|
||||
} else {
|
||||
$rows = $pagination['items'];
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'usage' => $usage,
|
||||
'stats' => self::placementStats($all, $filteredCount),
|
||||
'options' => self::placementOptions($all),
|
||||
'pagination' => $pagination,
|
||||
);
|
||||
}
|
||||
|
||||
public static function siteMap($query = '')
|
||||
{
|
||||
$query = self::lower(trim((string) $query));
|
||||
$navigationTable = ContentTables::table('navigation');
|
||||
$itemsTable = ContentTables::table('navigation_items');
|
||||
$documentsTable = ContentTables::table('documents');
|
||||
$menus = self::available($navigationTable)
|
||||
? DB::query('SELECT navigation_id id,title,alias FROM ' . $navigationTable . ' ORDER BY title,navigation_id')->getAll() ?: array()
|
||||
: array();
|
||||
$rows = self::available($itemsTable)
|
||||
? DB::query(
|
||||
'SELECT i.navigation_item_id id,i.navigation_id,i.parent_id,i.level,i.position,i.status,i.title,i.alias,i.document_id,'
|
||||
. ' d.document_title,d.document_alias,d.document_status,d.document_deleted'
|
||||
. ' FROM ' . $itemsTable . ' i LEFT JOIN ' . $documentsTable . ' d ON d.Id=i.document_id'
|
||||
. ' ORDER BY i.navigation_id,i.parent_id,i.position,i.navigation_item_id'
|
||||
)->getAll() ?: array()
|
||||
: array();
|
||||
|
||||
$itemsByMenu = array();
|
||||
$linkedDocuments = array();
|
||||
foreach ($rows as $row) {
|
||||
$row = self::siteMapItem($row);
|
||||
if ($query !== '' && strpos(self::lower($row['title'] . ' ' . $row['alias'] . ' ' . $row['document_title']), $query) === false) {
|
||||
$row['query_match'] = false;
|
||||
} else {
|
||||
$row['query_match'] = true;
|
||||
}
|
||||
|
||||
$itemsByMenu[$row['navigation_id']][] = $row;
|
||||
if ($row['document_id'] > 0) { $linkedDocuments[$row['document_id']] = true; }
|
||||
}
|
||||
|
||||
$totalItems = 0;
|
||||
foreach ($menus as &$menu) {
|
||||
$menu['id'] = (int) $menu['id'];
|
||||
$menu['title'] = self::decode($menu['title']);
|
||||
$menu['alias'] = (string) $menu['alias'];
|
||||
$menuItems = isset($itemsByMenu[$menu['id']]) ? $itemsByMenu[$menu['id']] : array();
|
||||
$menu['items_count'] = count($menuItems);
|
||||
$menu['tree'] = self::siteMapTree($menuItems, 0, $query !== '');
|
||||
$totalItems += $menu['items_count'];
|
||||
}
|
||||
|
||||
unset($menu);
|
||||
|
||||
$orphanSql = 'SELECT d.Id id,d.document_title title,d.document_alias alias,d.document_status status,r.rubric_title rubric_title'
|
||||
. ' FROM ' . $documentsTable . ' d'
|
||||
. ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id'
|
||||
. " WHERE d.document_deleted!='1'";
|
||||
$args = array();
|
||||
if ($linkedDocuments) {
|
||||
$orphanSql .= ' AND d.Id NOT IN %li';
|
||||
$args[] = array_keys($linkedDocuments);
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$orphanSql .= ' AND (LOWER(d.document_title) LIKE %s OR LOWER(d.document_alias) LIKE %s)';
|
||||
$args[] = '%' . $query . '%';
|
||||
$args[] = '%' . $query . '%';
|
||||
}
|
||||
|
||||
$orphanSql .= ' ORDER BY d.document_status DESC,d.document_title,d.Id LIMIT 100';
|
||||
$orphans = self::queryAll($orphanSql, $args);
|
||||
foreach ($orphans as &$orphan) {
|
||||
$orphan['id'] = (int) $orphan['id'];
|
||||
$orphan['title'] = self::decode($orphan['title']);
|
||||
$orphan['rubric_title'] = self::decode($orphan['rubric_title']);
|
||||
$orphan['active'] = (string) $orphan['status'] === '1';
|
||||
}
|
||||
|
||||
unset($orphan);
|
||||
|
||||
return array(
|
||||
'menus' => $menus,
|
||||
'orphans' => $orphans,
|
||||
'stats' => array(
|
||||
'menus' => count($menus),
|
||||
'items' => $totalItems,
|
||||
'linked_documents' => count($linkedDocuments),
|
||||
'orphans' => count($orphans),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function siteMapItem(array $row)
|
||||
{
|
||||
return array(
|
||||
'id' => (int) $row['id'],
|
||||
'navigation_id' => (int) $row['navigation_id'],
|
||||
'parent_id' => (int) $row['parent_id'],
|
||||
'level' => (int) $row['level'],
|
||||
'position' => (int) $row['position'],
|
||||
'active' => (string) $row['status'] === '1',
|
||||
'title' => self::decode($row['title']),
|
||||
'alias' => (string) $row['alias'],
|
||||
'document_id' => isset($row['document_id']) ? (int) $row['document_id'] : 0,
|
||||
'document_title' => self::decode(isset($row['document_title']) ? $row['document_title'] : ''),
|
||||
'document_alias' => isset($row['document_alias']) ? (string) $row['document_alias'] : '',
|
||||
'document_active' => isset($row['document_status']) && (string) $row['document_status'] === '1' && (string) $row['document_deleted'] !== '1',
|
||||
);
|
||||
}
|
||||
|
||||
protected static function siteMapTree(array $items, $parentId, $filtering, array $visited = array())
|
||||
{
|
||||
$tree = array();
|
||||
foreach ($items as $item) {
|
||||
if ((int) $item['parent_id'] !== (int) $parentId || isset($visited[$item['id']])) { continue; }
|
||||
$nextVisited = $visited;
|
||||
$nextVisited[$item['id']] = true;
|
||||
$item['children'] = self::siteMapTree($items, $item['id'], $filtering, $nextVisited);
|
||||
if (!$filtering || !empty($item['query_match']) || !empty($item['children'])) {
|
||||
$tree[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
protected static function rubricIssues(array $row)
|
||||
{
|
||||
$issues = array();
|
||||
if ((int) $row['template_id'] <= 0 || trim((string) $row['template_title']) === '') {
|
||||
$issues[] = array('level' => 'error', 'message' => 'Не назначен существующий шаблон сайта');
|
||||
}
|
||||
|
||||
if ((int) $row['template_length'] <= 0) {
|
||||
$issues[] = array('level' => 'warning', 'message' => 'Пустой основной шаблон рубрики');
|
||||
}
|
||||
|
||||
if ((int) $row['fields_count'] <= 0) {
|
||||
$issues[] = array('level' => 'warning', 'message' => 'У типа контента нет полей');
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
protected static function placementStats(array $rows, $filtered)
|
||||
{
|
||||
$sources = array();
|
||||
$total = 0;
|
||||
$broken = 0;
|
||||
$modules = 0;
|
||||
foreach ($rows as $row) {
|
||||
$sources[$row['source_type'] . ':' . $row['source_id']] = true;
|
||||
$total += max(1, (int) $row['occurrences']);
|
||||
$broken += $row['component_status'] === 'broken' ? 1 : 0;
|
||||
$modules += $row['component_type'] === 'module' ? 1 : 0;
|
||||
}
|
||||
|
||||
return array(
|
||||
'total' => $total,
|
||||
'rows' => count($rows),
|
||||
'filtered' => (int) $filtered,
|
||||
'sources' => count($sources),
|
||||
'broken' => $broken,
|
||||
'modules' => $modules,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function placementUsage(array $rows)
|
||||
{
|
||||
$usage = array();
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['component_type'] . ':' . strtolower((string) $row['component_key']);
|
||||
if (!isset($usage[$key])) {
|
||||
$usage[$key] = array(
|
||||
'component_type' => $row['component_type'],
|
||||
'component_title' => $row['component_title'],
|
||||
'component_alias' => $row['component_alias'],
|
||||
'component_key' => $row['component_key'],
|
||||
'component_status' => $row['component_status'],
|
||||
'component_status_title' => $row['component_status_title'],
|
||||
'component_url' => $row['component_url'],
|
||||
'placements' => 0,
|
||||
'sources' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
$usage[$key]['placements'] += max(1, (int) $row['occurrences']);
|
||||
$usage[$key]['sources'][$row['source_type'] . ':' . $row['source_id']] = true;
|
||||
if ($row['component_status'] === 'broken') {
|
||||
$usage[$key]['component_status'] = 'broken';
|
||||
$usage[$key]['component_status_title'] = $row['component_status_title'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($usage as &$item) {
|
||||
$item['sources'] = count($item['sources']);
|
||||
}
|
||||
|
||||
unset($item);
|
||||
$usage = array_values($usage);
|
||||
usort($usage, function ($left, $right) {
|
||||
return array(
|
||||
$left['component_status'] === 'broken' ? 0 : 1,
|
||||
-$left['placements'],
|
||||
$left['component_title'],
|
||||
) <=> array(
|
||||
$right['component_status'] === 'broken' ? 0 : 1,
|
||||
-$right['placements'],
|
||||
$right['component_title'],
|
||||
);
|
||||
});
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
protected static function paginate(array $items, $page, $limit)
|
||||
{
|
||||
$total = count($items);
|
||||
$limit = max(1, (int) $limit);
|
||||
$pages = max(1, (int) ceil($total / $limit));
|
||||
$page = min(max(1, (int) $page), $pages);
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
return array(
|
||||
'items' => array_slice($items, $offset, $limit),
|
||||
'page' => $page,
|
||||
'pages' => $pages,
|
||||
'total' => $total,
|
||||
'from' => $total > 0 ? $offset + 1 : 0,
|
||||
'to' => min($total, $offset + $limit),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function placementOptions(array $rows)
|
||||
{
|
||||
$options = array(
|
||||
'types' => array(),
|
||||
'states' => array(),
|
||||
'areas' => array(),
|
||||
);
|
||||
$typeTitles = array(
|
||||
'block' => 'Блоки',
|
||||
'request' => 'Подборки',
|
||||
'navigation' => 'Навигации',
|
||||
'module' => 'Модульные теги',
|
||||
);
|
||||
foreach ($rows as $row) {
|
||||
$options['types'][$row['component_type']] = isset($typeTitles[$row['component_type']])
|
||||
? $typeTitles[$row['component_type']]
|
||||
: $row['component_type'];
|
||||
$options['states'][$row['component_status']] = $row['component_status_title'];
|
||||
$options['areas'][$row['area']] = $row['area_title'];
|
||||
}
|
||||
|
||||
foreach ($options as &$values) {
|
||||
asort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
}
|
||||
|
||||
unset($values);
|
||||
return $options;
|
||||
}
|
||||
|
||||
protected static function documentCounts()
|
||||
{
|
||||
$table = ContentTables::table('documents');
|
||||
if (!self::available($table)) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT rubric_id,COUNT(*) total,'
|
||||
. " SUM(CASE WHEN document_status='1' AND document_deleted!='1' THEN 1 ELSE 0 END) published"
|
||||
. ' FROM ' . $table . " WHERE document_deleted!='1' GROUP BY rubric_id"
|
||||
)->getAll() ?: array();
|
||||
$counts = array();
|
||||
foreach ($rows as $row) {
|
||||
$counts[(int) $row['rubric_id']] = array(
|
||||
'total' => (int) $row['total'],
|
||||
'published' => (int) $row['published'],
|
||||
);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
protected static function groupedCount($table, $column)
|
||||
{
|
||||
if (!self::available($table) || !preg_match('/^[a-z0-9_]+$/', (string) $column)) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$rows = DB::query(
|
||||
'SELECT `' . $column . '` group_id,COUNT(*) amount FROM ' . $table
|
||||
. ' GROUP BY `' . $column . '`'
|
||||
)->getAll() ?: array();
|
||||
$counts = array();
|
||||
foreach ($rows as $row) { $counts[(int) $row['group_id']] = (int) $row['amount']; }
|
||||
return $counts;
|
||||
}
|
||||
|
||||
protected static function count($table, $where = '')
|
||||
{
|
||||
if (!self::available($table)) { return 0; }
|
||||
return (int) DB::query('SELECT COUNT(*) FROM ' . $table . ($where !== '' ? ' WHERE ' . $where : ''))->getValue();
|
||||
}
|
||||
|
||||
protected static function queryAll($sql, array $args)
|
||||
{
|
||||
return call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array();
|
||||
}
|
||||
|
||||
protected static function available($table)
|
||||
{
|
||||
try {
|
||||
return DatabaseSchema::tableExists($table);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function decode($value)
|
||||
{
|
||||
return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function lower($value)
|
||||
{
|
||||
$value = (string) $value;
|
||||
return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/PublicSite/PresentationModel.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\PublicSite;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\DatabaseSchema;
|
||||
use App\Common\ModuleManager;
|
||||
use App\Content\CatalogTables;
|
||||
use App\Content\ContentTables;
|
||||
use App\Content\Documents\DocumentPickerRepository;
|
||||
use App\Content\Presentation\PresentationAssignmentRepository;
|
||||
use App\Content\Presentation\PresentationPreview;
|
||||
use App\Content\Presentation\PresentationRepository;
|
||||
use App\Content\Presentation\PresentationSyntax;
|
||||
use DB;
|
||||
|
||||
class PresentationModel
|
||||
{
|
||||
protected static $targets;
|
||||
|
||||
public static function documents($limit = 100)
|
||||
{
|
||||
return (new DocumentPickerRepository())->search('', array(), max(1, min(100, (int) $limit)));
|
||||
}
|
||||
|
||||
public static function legacySources()
|
||||
{
|
||||
$sources = array();
|
||||
self::appendLegacy(
|
||||
$sources,
|
||||
CatalogTables::table('catalog_card_templates'),
|
||||
'Товарные карточки',
|
||||
'/catalog/card-templates',
|
||||
'ti-layout-cards'
|
||||
);
|
||||
self::appendLegacy(
|
||||
$sources,
|
||||
CatalogTables::table('catalog_filter_templates'),
|
||||
'Фильтры товарного каталога',
|
||||
'/catalog/filter-templates',
|
||||
'ti-adjustments-horizontal'
|
||||
);
|
||||
return $sources;
|
||||
}
|
||||
|
||||
public static function assignmentTargets()
|
||||
{
|
||||
if (self::$targets !== null) { return self::$targets; }
|
||||
$targets = array(
|
||||
'global' => array(array(
|
||||
'key' => '0', 'title' => 'Везде', 'meta' => 'Общее назначение',
|
||||
'url' => '', 'available' => true,
|
||||
)),
|
||||
'rubric' => array(),
|
||||
'request' => array(),
|
||||
'catalog' => array(),
|
||||
'module' => array(),
|
||||
);
|
||||
$rubrics = ContentTables::table('rubrics');
|
||||
if (DatabaseSchema::tableExists($rubrics)) {
|
||||
$rows = DB::query('SELECT Id,rubric_title FROM ' . $rubrics . ' ORDER BY rubric_title,Id')->getAll() ?: array();
|
||||
foreach ($rows as $row) {
|
||||
$targets['rubric'][] = array(
|
||||
'key' => (string) (int) $row['Id'],
|
||||
'title' => self::decode($row['rubric_title']),
|
||||
'meta' => 'Рубрика #' . (int) $row['Id'],
|
||||
'url' => '/rubrics/' . (int) $row['Id'],
|
||||
'available' => true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$requests = ContentTables::table('request');
|
||||
if (DatabaseSchema::tableExists($requests)) {
|
||||
$rows = DB::query('SELECT Id,request_title,request_alias FROM ' . $requests . ' ORDER BY request_title,Id')->getAll() ?: array();
|
||||
foreach ($rows as $row) {
|
||||
$title = self::decode($row['request_title']);
|
||||
if ($title === '') { $title = 'Запрос #' . (int) $row['Id']; }
|
||||
$targets['request'][] = array(
|
||||
'key' => (string) (int) $row['Id'],
|
||||
'title' => $title,
|
||||
'meta' => trim((string) $row['request_alias']) !== '' ? (string) $row['request_alias'] : 'Запрос #' . (int) $row['Id'],
|
||||
'url' => '/requests/' . (int) $row['Id'],
|
||||
'available' => true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$items = CatalogTables::table('module_catalog_items');
|
||||
$settings = CatalogTables::table('module_catalog_settings');
|
||||
if (DatabaseSchema::tableExists($items) && DatabaseSchema::tableExists($settings)) {
|
||||
$rows = DB::query(
|
||||
'SELECT i.id,i.name,i.level,i.rubric_id,i.field_id,COALESCE(s.purpose,\'content\') purpose'
|
||||
. ' FROM ' . $items . ' i LEFT JOIN ' . $settings
|
||||
. ' s ON s.rubric_id=i.rubric_id AND s.field_id=i.field_id'
|
||||
. ' ORDER BY s.purpose DESC,i.rubric_id,i.field_id,i.level,i.position,i.id'
|
||||
)->getAll() ?: array();
|
||||
foreach ($rows as $row) {
|
||||
$targets['catalog'][] = array(
|
||||
'key' => (string) (int) $row['id'],
|
||||
'title' => str_repeat('— ', max(0, min(5, (int) $row['level']))) . self::decode($row['name']),
|
||||
'meta' => ((string) $row['purpose'] === 'commerce' ? 'Товарный раздел' : 'Раздел контента') . ' #' . (int) $row['id'],
|
||||
'url' => '/catalog/' . (int) $row['rubric_id'] . '/' . (int) $row['field_id'] . '?item=' . (int) $row['id'],
|
||||
'available' => true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ModuleManager::all() as $module) {
|
||||
if (empty($module['installed']) || empty($module['enabled'])) { continue; }
|
||||
$extension = isset($module['admin_extension']) && is_array($module['admin_extension'])
|
||||
? $module['admin_extension']
|
||||
: array();
|
||||
$url = isset($extension['url']) ? trim((string) $extension['url']) : '';
|
||||
if ($url === '') { $url = '/modules/' . (string) $module['code']; }
|
||||
$targets['module'][] = array(
|
||||
'key' => strtolower((string) $module['code']),
|
||||
'title' => (string) $module['name'],
|
||||
'meta' => 'Модуль · ' . (string) $module['code'],
|
||||
'url' => $url,
|
||||
'available' => true,
|
||||
);
|
||||
}
|
||||
|
||||
usort($targets['module'], function ($left, $right) {
|
||||
return strcasecmp((string) $left['title'], (string) $right['title']);
|
||||
});
|
||||
self::$targets = $targets;
|
||||
return self::$targets;
|
||||
}
|
||||
|
||||
public static function assignments($presentationId)
|
||||
{
|
||||
$targets = self::assignmentTargetMap();
|
||||
$items = PresentationAssignmentRepository::all((int) $presentationId);
|
||||
foreach ($items as &$item) {
|
||||
$key = (string) $item['target_type'] . ':' . (string) $item['target_key'];
|
||||
$target = isset($targets[$key]) ? $targets[$key] : null;
|
||||
$item['target_title'] = $target ? (string) $target['title'] : 'Цель больше не существует';
|
||||
$item['target_meta'] = $target ? (string) $target['meta'] : $key;
|
||||
$item['target_url'] = $target ? (string) $target['url'] : '';
|
||||
$item['target_available'] = (bool) $target;
|
||||
}
|
||||
|
||||
unset($item);
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function validateAssignment(array $input)
|
||||
{
|
||||
$type = strtolower(trim(isset($input['target_type']) ? (string) $input['target_type'] : ''));
|
||||
$key = strtolower(trim(isset($input['target_key']) ? (string) $input['target_key'] : ''));
|
||||
if ($type === 'global') { $key = '0'; }
|
||||
$targets = self::assignmentTargetMap();
|
||||
if (!isset($targets[$type . ':' . $key])) {
|
||||
throw new \InvalidArgumentException('Выберите существующую цель назначения');
|
||||
}
|
||||
|
||||
$input['target_type'] = $type;
|
||||
$input['target_key'] = $key;
|
||||
return $input;
|
||||
}
|
||||
|
||||
public static function diagnose($presentationId, array $input)
|
||||
{
|
||||
$presentationId = (int) $presentationId;
|
||||
$item = PresentationRepository::one($presentationId);
|
||||
if (!$item) { throw new \RuntimeException('Представление не найдено'); }
|
||||
$checks = array();
|
||||
$markup = array(
|
||||
'Элемент' => isset($input['draft_item_markup']) ? (string) $input['draft_item_markup'] : (string) $item['draft_item_markup'],
|
||||
'Обёртка' => isset($input['draft_wrapper_markup']) ? (string) $input['draft_wrapper_markup'] : (string) $item['draft_wrapper_markup'],
|
||||
'Пустой результат' => isset($input['draft_empty_markup']) ? (string) $input['draft_empty_markup'] : (string) $item['draft_empty_markup'],
|
||||
);
|
||||
if (trim($markup['Элемент']) === '') {
|
||||
$checks[] = self::check('error', 'Шаблон элемента пуст', 'Заполните разметку одного материала.');
|
||||
} else {
|
||||
$syntax = PresentationSyntax::checkMany($markup);
|
||||
$checks[] = self::check($syntax['ok'] ? 'ok' : 'error', 'Twig-шаблоны', $syntax['message']);
|
||||
}
|
||||
|
||||
$assignments = self::assignments($presentationId);
|
||||
if (!$assignments) {
|
||||
$checks[] = self::check('warning', 'Нет назначений', 'Представление сохранено, но пока нигде не используется.', '/public-site/presentations');
|
||||
} else {
|
||||
$missing = array_filter($assignments, function ($assignment) { return empty($assignment['target_available']); });
|
||||
$native = array_filter($assignments, function ($assignment) { return (string) $assignment['mode'] === 'native'; });
|
||||
$checks[] = self::check(
|
||||
$missing ? 'error' : 'ok',
|
||||
'Цели назначения',
|
||||
$missing ? 'Есть связи с удалёнными рубриками, запросами, разделами или модулями.' : 'Все ' . count($assignments) . ' назначений ведут к существующим целям.'
|
||||
);
|
||||
if ($native && empty($item['is_published'])) {
|
||||
$checks[] = self::check('error', 'Новое представление не опубликовано', 'Native-назначение начнёт работать только после публикации.');
|
||||
} elseif ($native) {
|
||||
$checks[] = self::check('ok', 'Публичный режим', count($native) . ' назначений используют опубликованную версию.');
|
||||
} else {
|
||||
$checks[] = self::check('ok', 'Публичный сайт защищён', 'Назначений native нет: текущая разметка сайта не заменяется.');
|
||||
}
|
||||
}
|
||||
|
||||
$documentId = isset($input['preview_document_id']) ? (int) $input['preview_document_id'] : 0;
|
||||
if ($documentId > 0) {
|
||||
try {
|
||||
PresentationPreview::render($documentId, $input);
|
||||
$checks[] = self::check('ok', 'Предпросмотр', 'Черновик успешно собран на документе #' . $documentId . '.');
|
||||
} catch (\Throwable $e) {
|
||||
$checks[] = self::check('error', 'Предпросмотр', $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
$checks[] = self::check('warning', 'Предпросмотр не проверен', 'Выберите реальный материал и повторите диагностику.');
|
||||
}
|
||||
|
||||
$summary = array('ok' => 0, 'warning' => 0, 'error' => 0);
|
||||
foreach ($checks as $check) { $summary[$check['status']]++; }
|
||||
return array(
|
||||
'ready' => $summary['error'] === 0,
|
||||
'summary' => $summary,
|
||||
'checks' => $checks,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function appendLegacy(array &$sources, $table, $title, $url, $icon)
|
||||
{
|
||||
if (!DatabaseSchema::tableExists($table)) { return; }
|
||||
$sources[] = array(
|
||||
'title' => (string) $title,
|
||||
'url' => (string) $url,
|
||||
'icon' => (string) $icon,
|
||||
'count' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function assignmentTargetMap()
|
||||
{
|
||||
$map = array();
|
||||
foreach (self::assignmentTargets() as $type => $items) {
|
||||
foreach ($items as $item) { $map[$type . ':' . (string) $item['key']] = $item; }
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
protected static function check($status, $title, $message, $url = '')
|
||||
{
|
||||
return array(
|
||||
'status' => (string) $status,
|
||||
'title' => (string) $title,
|
||||
'message' => (string) $message,
|
||||
'url' => (string) $url,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function decode($value)
|
||||
{
|
||||
return html_entity_decode(stripslashes((string) $value), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/PublicSite/PresentationsController.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\PublicSite;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Adminx\Support\CodeEditor;
|
||||
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\Presentation\PresentationAssignmentRepository;
|
||||
use App\Content\Presentation\PresentationDataRegistry;
|
||||
use App\Content\Presentation\PresentationPreview;
|
||||
use App\Content\Presentation\PresentationRepository;
|
||||
use App\Content\Presentation\PresentationRevisionRepository;
|
||||
use App\Content\Presentation\PresentationSyntax;
|
||||
use App\Helpers\Request;
|
||||
|
||||
class PresentationsController extends BaseController
|
||||
{
|
||||
public function index(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) {
|
||||
return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403);
|
||||
}
|
||||
|
||||
AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/presentations.css', 51);
|
||||
AdminAssets::addScript($this->base() . '/modules/PublicSite/assets/presentations.js', 51);
|
||||
CodeEditor::useCodeMirror('htmlmixed');
|
||||
CodeEditor::useCodeMirror('text/css');
|
||||
$query = Request::getStr('q', '');
|
||||
$kind = Request::getStr('kind', '');
|
||||
$state = Request::getStr('state', '');
|
||||
return $this->render('@public_site/presentations.twig', array(
|
||||
'available' => PresentationRepository::available(),
|
||||
'presentations' => PresentationRepository::all($query, $kind, $state),
|
||||
'stats' => PresentationRepository::stats(),
|
||||
'kinds' => PresentationRepository::kinds(),
|
||||
'filters' => array('q' => $query, 'kind' => $kind, 'state' => $state),
|
||||
'data_groups' => PresentationDataRegistry::groups(true),
|
||||
'documents' => PresentationModel::documents(),
|
||||
'legacy_sources' => PresentationModel::legacySources(),
|
||||
'assignment_targets' => PresentationModel::assignmentTargets(),
|
||||
'can_manage' => Permission::check('manage_public_presentations'),
|
||||
'defaults' => $this->defaults(),
|
||||
));
|
||||
}
|
||||
|
||||
public function show(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); }
|
||||
$item = PresentationRepository::one(isset($params['id']) ? (int) $params['id'] : 0);
|
||||
if (!$item) { return $this->error('Представление не найдено', array(), 404); }
|
||||
$item['assignments'] = PresentationModel::assignments($item['id']);
|
||||
return $this->success('', array('data' => $item));
|
||||
}
|
||||
|
||||
public function store(array $params = array())
|
||||
{
|
||||
return $this->persist(0);
|
||||
}
|
||||
|
||||
public function update(array $params = array())
|
||||
{
|
||||
return $this->persist(isset($params['id']) ? (int) $params['id'] : 0);
|
||||
}
|
||||
|
||||
public function lint(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$result = PresentationSyntax::checkMany($this->markup(Request::postAll()));
|
||||
return $result['ok']
|
||||
? $this->success($result['message'], array('data' => $result))
|
||||
: $this->error($result['message'], array(), 422);
|
||||
}
|
||||
|
||||
public function preview(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
try {
|
||||
$result = PresentationPreview::render(Request::postInt('preview_document_id', 0), Request::postAll());
|
||||
$css = Request::postStr('draft_css', '');
|
||||
$html = '<!doctype html><html><head><meta charset="utf-8">'
|
||||
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<style>html,body{margin:0;min-height:100%;background:#f5f7fa;color:#172033;font:14px/1.5 Arial,sans-serif}'
|
||||
. 'body{padding:24px}*{box-sizing:border-box}a{color:#2563eb}</style>'
|
||||
. ($css !== '' ? '<style>' . $css . '</style>' : '')
|
||||
. '</head><body><main>' . $result['markup'] . '</main></body></html>';
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), array(), 422);
|
||||
}
|
||||
|
||||
return $this->success('Предпросмотр обновлён', array('data' => array(
|
||||
'html' => $html,
|
||||
'json' => $result['json'],
|
||||
'document' => array(
|
||||
'id' => isset($result['data']['item']['id']) ? (int) $result['data']['item']['id'] : 0,
|
||||
'title' => isset($result['data']['item']['title']) ? (string) $result['data']['item']['title'] : '',
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
public function diagnose(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
try {
|
||||
$result = PresentationModel::diagnose(
|
||||
isset($params['id']) ? (int) $params['id'] : 0,
|
||||
Request::postAll()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), array(), 422);
|
||||
}
|
||||
|
||||
$warnings = isset($result['summary']['warning']) ? (int) $result['summary']['warning'] : 0;
|
||||
return $this->success(
|
||||
$result['ready'] ? ($warnings ? 'Ошибок нет, есть предупреждения' : 'Представление готово') : 'Найдены ошибки',
|
||||
array('data' => $result)
|
||||
);
|
||||
}
|
||||
|
||||
public function publish(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$id = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
try { $item = PresentationRepository::publish($id, Auth::id()); }
|
||||
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
|
||||
$this->audit('public_site.presentation_published', $id, array('version' => $item['version']));
|
||||
return $this->success('Представление опубликовано', array('data' => $item));
|
||||
}
|
||||
|
||||
public function copy(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
try { $id = PresentationRepository::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); }
|
||||
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
|
||||
$this->audit('public_site.presentation_copied', $id, array());
|
||||
return $this->success('Копия создана', array('data' => array('id' => $id)));
|
||||
}
|
||||
|
||||
public function delete(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$id = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
try { $deleted = PresentationRepository::delete($id); }
|
||||
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
|
||||
if (!$deleted) { return $this->error('Представление не найдено', array(), 404); }
|
||||
$this->audit('public_site.presentation_deleted', $id, array());
|
||||
return $this->success('Представление удалено');
|
||||
}
|
||||
|
||||
public function revisions(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); }
|
||||
$id = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
if (!PresentationRepository::one($id)) { return $this->error('Представление не найдено', array(), 404); }
|
||||
return $this->success('', array('data' => array('revisions' => PresentationRevisionRepository::all($id))));
|
||||
}
|
||||
|
||||
public function revision(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); }
|
||||
$item = PresentationRevisionRepository::one(isset($params['revision']) ? (int) $params['revision'] : 0);
|
||||
return $item ? $this->success('', array('data' => $item)) : $this->error('Ревизия не найдена', array(), 404);
|
||||
}
|
||||
|
||||
public function restoreRevision(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
try { $id = PresentationRevisionRepository::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); }
|
||||
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
|
||||
$this->audit('public_site.presentation_revision_restored', $id, array());
|
||||
return $this->success('Ревизия восстановлена в черновик', array('data' => array('id' => $id)));
|
||||
}
|
||||
|
||||
public function deleteRevision(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$id = PresentationRevisionRepository::delete(isset($params['revision']) ? (int) $params['revision'] : 0);
|
||||
return $id ? $this->success('Ревизия удалена') : $this->error('Ревизия не найдена', array(), 404);
|
||||
}
|
||||
|
||||
public function deleteRevisions(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$id = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
if (!PresentationRepository::one($id)) { return $this->error('Представление не найдено', array(), 404); }
|
||||
return $this->success('Ревизии удалены', array('data' => array(
|
||||
'count' => PresentationRevisionRepository::deleteAll($id),
|
||||
)));
|
||||
}
|
||||
|
||||
public function saveAssignment(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
$presentationId = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
try {
|
||||
$input = PresentationModel::validateAssignment(Request::postAll());
|
||||
$id = PresentationAssignmentRepository::save($presentationId, $input, Auth::id());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), array(), 422);
|
||||
}
|
||||
|
||||
$this->audit('public_site.presentation_assignment_saved', $presentationId, array('assignment_id' => $id));
|
||||
return $this->success('Назначение сохранено', array('data' => array(
|
||||
'assignments' => PresentationModel::assignments($presentationId),
|
||||
)));
|
||||
}
|
||||
|
||||
public function deleteAssignment(array $params = array())
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
PresentationAssignmentRepository::delete(isset($params['assignment']) ? (int) $params['assignment'] : 0);
|
||||
return $this->success('Назначение удалено');
|
||||
}
|
||||
|
||||
protected function persist($id)
|
||||
{
|
||||
if (($error = $this->guard()) !== null) { return $error; }
|
||||
try { $savedId = PresentationRepository::save((int) $id, Request::postAll(), Auth::id()); }
|
||||
catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); }
|
||||
$this->audit($id > 0 ? 'public_site.presentation_updated' : 'public_site.presentation_created', $savedId, array());
|
||||
return $this->success($id > 0 ? 'Черновик сохранён' : 'Представление создано', array(
|
||||
'data' => array('id' => $savedId),
|
||||
));
|
||||
}
|
||||
|
||||
protected function guard()
|
||||
{
|
||||
return $this->guardPermission('manage_public_presentations');
|
||||
}
|
||||
|
||||
protected function markup(array $input)
|
||||
{
|
||||
return array(
|
||||
'Элемент' => isset($input['draft_item_markup']) ? (string) $input['draft_item_markup'] : '',
|
||||
'Обёртка' => isset($input['draft_wrapper_markup']) ? (string) $input['draft_wrapper_markup'] : '',
|
||||
'Пустой результат' => isset($input['draft_empty_markup']) ? (string) $input['draft_empty_markup'] : '',
|
||||
);
|
||||
}
|
||||
|
||||
protected function defaults()
|
||||
{
|
||||
return array(
|
||||
'item' => '<article class="content-card">' . "\n"
|
||||
. ' <h2><a href="{{ item.url }}">{{ item.title }}</a></h2>' . "\n"
|
||||
. ' {% if item.excerpt %}<p>{{ item.excerpt }}</p>{% endif %}' . "\n"
|
||||
. '</article>',
|
||||
'wrapper' => '<div class="content-list">' . "\n" . ' {{ content|raw }}' . "\n" . '</div>',
|
||||
'empty' => '<div class="empty-state">Материалы не найдены.</div>',
|
||||
);
|
||||
}
|
||||
|
||||
protected function audit($action, $targetId, array $meta)
|
||||
{
|
||||
$user = Auth::user();
|
||||
AuditLog::record($action, array(
|
||||
'actor_id' => Auth::id(),
|
||||
'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '',
|
||||
'target_type' => 'presentation',
|
||||
'target_id' => (int) $targetId,
|
||||
'meta' => $meta,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
.public-presentations-schema,
|
||||
.public-site-tabs {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.public-presentations-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.public-presentation-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
.public-presentation-stat b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.public-presentation-stat span:not(.icon-tile) {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-presentations-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.public-presentations-section-head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.public-presentations-card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.public-presentations-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(160px, 210px) minmax(160px, 210px) auto auto;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-presentations-card .table-scroll {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
.public-presentations-table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
}
|
||||
.presentation-col-id {
|
||||
width: 62px;
|
||||
}
|
||||
.presentation-col-kind {
|
||||
width: 180px;
|
||||
}
|
||||
.presentation-col-state {
|
||||
width: 180px;
|
||||
}
|
||||
.presentation-col-date {
|
||||
width: 145px;
|
||||
}
|
||||
.presentation-col-actions {
|
||||
width: 168px;
|
||||
}
|
||||
.public-presentation-name {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.public-presentation-name small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-version {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.public-presentation-actions .is-edit {
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.public-presentation-actions .is-history {
|
||||
color: var(--violet-600);
|
||||
}
|
||||
.public-presentation-actions .is-copy {
|
||||
color: var(--cyan-600);
|
||||
}
|
||||
.public-presentation-actions .is-danger {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-presentation-legacy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.public-presentation-legacy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-presentation-legacy > span:nth-child(2) {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-presentation-legacy small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-legacy > i {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-drawer.drawer-lg {
|
||||
width: min(1180px, 66.666vw);
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.public-presentation-form {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.public-presentation-form .drawer-body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.public-presentation-basics {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.public-presentation-editor-tabs {
|
||||
margin-bottom: 14px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.public-presentation-editor-tabs .tab {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.public-presentation-editor,
|
||||
.public-presentation-data,
|
||||
.public-presentation-preview,
|
||||
.public-presentation-assignments,
|
||||
.public-presentation-diagnostics,
|
||||
.public-presentation-revisions {
|
||||
min-width: 0;
|
||||
}
|
||||
.public-presentation-editor-head,
|
||||
.public-presentation-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-presentation-editor-head > div:first-child,
|
||||
.public-presentation-panel-head > div:first-child {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-presentation-editor-head small,
|
||||
.public-presentation-panel-head small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-data-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.public-presentation-data-groups section {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.public-presentation-data-groups header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.public-presentation-data-groups section > div {
|
||||
display: grid;
|
||||
padding: 6px;
|
||||
}
|
||||
.public-presentation-data-groups button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(110px, auto);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.public-presentation-data-groups button:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-presentation-data-groups button span {
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
.public-presentation-preview > .field {
|
||||
max-width: 720px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.public-presentation-preview-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-presentation-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.public-presentation-preview-grid iframe,
|
||||
.public-presentation-preview-grid pre {
|
||||
width: 100%;
|
||||
min-height: 430px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.public-presentation-preview-grid pre {
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.public-presentation-assignment-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.public-presentation-assignment {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.public-presentation-assignment > span:first-child,
|
||||
.public-presentation-assignment-target {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-presentation-assignment-target {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-presentation-assignment-target small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-assignment-target[href]:hover b {
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.public-presentation-assignment-target.is-missing {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-presentation-assignment-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-presentation-diagnostic-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.public-presentation-diagnostic-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.public-presentation-diagnostic {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.public-presentation-diagnostic > i {
|
||||
font-size: 20px;
|
||||
}
|
||||
.public-presentation-diagnostic > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-presentation-diagnostic small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-diagnostic.is-ok > i {
|
||||
color: var(--green-600);
|
||||
}
|
||||
.public-presentation-diagnostic.is-warning > i {
|
||||
color: var(--amber-600);
|
||||
}
|
||||
.public-presentation-diagnostic.is-error > i {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-presentation-revision-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.public-presentation-revision-list > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.public-presentation-revision-list > button.is-active,
|
||||
.public-presentation-revision-list > button:hover {
|
||||
border-color: var(--blue-400);
|
||||
background: var(--blue-50);
|
||||
}
|
||||
.public-presentation-revision-list span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-presentation-revision-list small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-presentation-revision-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-presentation-revision-detail pre {
|
||||
min-height: 160px;
|
||||
max-height: 340px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.public-presentation-footer {
|
||||
gap: 10px;
|
||||
}
|
||||
.public-presentation-footer .public-presentation-save-state {
|
||||
margin-right: auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-presentation-footer .public-presentation-save-state.is-dirty {
|
||||
color: var(--amber-700);
|
||||
}
|
||||
.public-presentation-footer .public-presentation-save-state.is-ok {
|
||||
color: var(--green-700);
|
||||
}
|
||||
.public-presentation-footer .public-presentation-save-state.is-error {
|
||||
color: var(--red-700);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.public-presentations-filter {
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(150px, 190px)) auto;
|
||||
}
|
||||
.public-presentations-filter .btn-ghost {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
.public-presentation-drawer.drawer-lg {
|
||||
width: min(920px, calc(100vw - 24px));
|
||||
}
|
||||
.public-presentation-assignment-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.public-presentations-summary,
|
||||
.public-presentation-legacy-grid,
|
||||
.public-presentation-data-groups,
|
||||
.public-presentation-preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-presentations-filter,
|
||||
.public-presentation-assignment-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
table.table.public-presentations-table {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
table.table.public-presentations-table colgroup,
|
||||
table.table.public-presentations-table thead {
|
||||
display: none;
|
||||
}
|
||||
table.table.public-presentations-table tbody,
|
||||
table.table.public-presentations-table tr,
|
||||
table.table.public-presentations-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
table.table.public-presentations-table tr {
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
table.table.public-presentations-table td {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 30%) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
}
|
||||
table.table.public-presentations-table td::before {
|
||||
color: var(--text-secondary);
|
||||
content: attr(data-label);
|
||||
font-size: 12px;
|
||||
}
|
||||
table.table.public-presentations-table .public-presentation-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
table.table.public-presentations-table .public-presentation-empty-row {
|
||||
padding: 0;
|
||||
}
|
||||
table.table.public-presentations-table .public-presentation-empty-row td {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
table.table.public-presentations-table .public-presentation-empty-row td::before {
|
||||
display: none;
|
||||
}
|
||||
table.table.public-presentations-table .public-presentation-empty-row .empty-state {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 28px 16px;
|
||||
white-space: normal;
|
||||
}
|
||||
.public-presentation-drawer.drawer-lg {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
.public-presentation-assignment {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.public-presentation-preview-grid iframe,
|
||||
.public-presentation-preview-grid pre {
|
||||
min-height: 320px;
|
||||
}
|
||||
.public-presentation-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.public-presentation-footer .public-presentation-save-state {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var Adminx = window.Adminx || (window.Adminx = {});
|
||||
|
||||
Adminx.PublicPresentations = {
|
||||
form: null,
|
||||
drawer: null,
|
||||
dirty: false,
|
||||
loading: false,
|
||||
needsReload: false,
|
||||
currentRevision: 0,
|
||||
activeEditor: 'draft_item_markup',
|
||||
targetCatalog: {},
|
||||
|
||||
init: function () {
|
||||
this.form = document.getElementById('publicPresentationForm');
|
||||
this.drawer = document.getElementById('publicPresentationDrawer');
|
||||
if (!this.form || !this.drawer) { return; }
|
||||
this.targetCatalog = this.readJson('[data-presentation-targets]');
|
||||
this.targetTypeChanged();
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (target.closest('[data-presentation-new]')) { self.openNew(); return; }
|
||||
var edit = target.closest('[data-presentation-edit]');
|
||||
if (edit) { self.open(Number(edit.getAttribute('data-presentation-edit'))); return; }
|
||||
var history = target.closest('[data-presentation-revisions]');
|
||||
if (history) { self.open(Number(history.getAttribute('data-presentation-revisions')), 'revisions'); return; }
|
||||
var copy = target.closest('[data-presentation-copy]');
|
||||
if (copy) { self.copy(Number(copy.getAttribute('data-presentation-copy'))); return; }
|
||||
var remove = target.closest('[data-presentation-delete]');
|
||||
if (remove) { self.remove(Number(remove.getAttribute('data-presentation-delete'))); return; }
|
||||
var tab = target.closest('[data-presentation-tab]');
|
||||
if (tab) { self.tab(tab.getAttribute('data-presentation-tab')); return; }
|
||||
if (target.closest('[data-presentation-save]')) { self.save().catch(function () {}); return; }
|
||||
if (target.closest('[data-presentation-publish]')) { self.publish(); return; }
|
||||
if (target.closest('[data-presentation-lint]')) { self.lint(); return; }
|
||||
if (target.closest('[data-presentation-preview]')) { self.preview(); return; }
|
||||
if (target.closest('[data-presentation-diagnose]')) { self.diagnose(); return; }
|
||||
var fullscreen = target.closest('[data-presentation-fullscreen]');
|
||||
if (fullscreen) { self.fullscreen(fullscreen.getAttribute('data-presentation-fullscreen')); return; }
|
||||
var variable = target.closest('[data-presentation-variable]');
|
||||
if (variable) { self.insertVariable(variable.getAttribute('data-presentation-variable')); return; }
|
||||
if (target.closest('[data-presentation-assignment-save]')) { self.saveAssignment(); return; }
|
||||
var assignmentDelete = target.closest('[data-presentation-assignment-delete]');
|
||||
if (assignmentDelete) { self.deleteAssignment(Number(assignmentDelete.getAttribute('data-presentation-assignment-delete'))); return; }
|
||||
var revision = target.closest('[data-presentation-revision]');
|
||||
if (revision) { self.showRevision(Number(revision.getAttribute('data-presentation-revision'))); return; }
|
||||
if (target.closest('[data-presentation-revision-restore]')) { self.restoreRevision(); return; }
|
||||
if (target.closest('[data-presentation-revision-delete]')) { self.deleteRevision(); return; }
|
||||
if (target.closest('[data-presentation-revisions-clear]')) { self.clearRevisions(); }
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var close = event.target.closest('[data-close-drawer]');
|
||||
if (!close || !self.drawer.contains(close)) { return; }
|
||||
if (self.dirty) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
self.confirmDiscard();
|
||||
return;
|
||||
}
|
||||
if (self.needsReload) { window.location.reload(); }
|
||||
}, true);
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if ((event.ctrlKey || event.metaKey) && String(event.key).toLowerCase() === 's' && self.drawer.classList.contains('show')) {
|
||||
event.preventDefault();
|
||||
self.save().catch(function () {});
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && self.dirty && self.drawer.classList.contains('show')) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
self.confirmDiscard();
|
||||
}
|
||||
}, true);
|
||||
|
||||
this.form.addEventListener('input', function (event) {
|
||||
if (!self.loading && !event.target.closest('.public-presentation-assignment-form')) { self.setDirty(true); }
|
||||
});
|
||||
this.form.addEventListener('change', function (event) {
|
||||
if (!self.loading && !event.target.closest('.public-presentation-assignment-form') && event.target.name !== 'preview_document_id') {
|
||||
self.setDirty(true);
|
||||
}
|
||||
if (event.target.name === 'assignment_target_type') { self.targetTypeChanged(); }
|
||||
});
|
||||
this.form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
self.save().catch(function () {});
|
||||
});
|
||||
setTimeout(function () { self.bindEditors(); }, 120);
|
||||
},
|
||||
|
||||
base: function () { return this.form.getAttribute('data-base') || ''; },
|
||||
resource: function () { return this.base() + '/public-site/presentations'; },
|
||||
revisionResource: function () { return this.base() + '/public-site/presentation-revisions'; },
|
||||
assignmentResource: function () { return this.base() + '/public-site/presentation-assignments'; },
|
||||
id: function () { return Number(this.field('id').value) || 0; },
|
||||
field: function (name) { return this.form.querySelector('[name="' + name + '"]'); },
|
||||
editor: function (name) {
|
||||
var field = this.field(name);
|
||||
return field && field._adminxCodeMirror ? field._adminxCodeMirror : null;
|
||||
},
|
||||
|
||||
request: function (url, options) {
|
||||
return Adminx.Ajax.request(url, options || {}).then(function (response) {
|
||||
var payload = response.data || {};
|
||||
if (!response.ok || !payload.success) {
|
||||
var error = new Error(payload.message || ('HTTP ' + response.status));
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
},
|
||||
|
||||
bindEditors: function () {
|
||||
var self = this;
|
||||
['draft_item_markup', 'draft_wrapper_markup', 'draft_empty_markup', 'draft_css'].forEach(function (name) {
|
||||
var editor = self.editor(name);
|
||||
if (!editor || editor._publicPresentationBound) { return; }
|
||||
editor._publicPresentationBound = true;
|
||||
editor.on('focus', function () { self.activeEditor = name; });
|
||||
editor.on('change', function () {
|
||||
if (!self.loading) { self.setDirty(true); }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
openNew: function () {
|
||||
this.loading = true;
|
||||
this.form.reset();
|
||||
this.field('id').value = '';
|
||||
this.field('title').value = 'Новое представление';
|
||||
this.field('code').value = 'presentation_' + String(Date.now()).slice(-8);
|
||||
this.field('kind').value = 'card';
|
||||
this.setCode('draft_item_markup', this.defaultValue('item'));
|
||||
this.setCode('draft_wrapper_markup', this.defaultValue('wrapper'));
|
||||
this.setCode('draft_empty_markup', this.defaultValue('empty'));
|
||||
this.setCode('draft_css', '');
|
||||
this.loading = false;
|
||||
this.needsReload = false;
|
||||
this.currentRevision = 0;
|
||||
this.setStatus({is_published: false, version: 0, has_changes: true});
|
||||
this.renderAssignments([]);
|
||||
this.clearRevisionView();
|
||||
this.clearPreview();
|
||||
this.clearDiagnostics();
|
||||
this.setDirty(false);
|
||||
this.tab('item');
|
||||
Adminx.Drawer.open('publicPresentationDrawer');
|
||||
this.refreshEditors();
|
||||
},
|
||||
|
||||
open: function (id, panel) {
|
||||
var self = this;
|
||||
Adminx.Loader.show();
|
||||
this.request(this.resource() + '/' + id).then(function (payload) {
|
||||
self.fill(payload.data || {});
|
||||
self.tab(panel || 'item');
|
||||
Adminx.Drawer.open('publicPresentationDrawer');
|
||||
self.refreshEditors();
|
||||
if (panel === 'revisions') { self.loadRevisions(); }
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
}).finally(function () {
|
||||
Adminx.Loader.hide();
|
||||
});
|
||||
},
|
||||
|
||||
fill: function (item) {
|
||||
this.loading = true;
|
||||
['id', 'title', 'code', 'kind', 'description', 'draft_settings_json'].forEach(function (name) {
|
||||
var field = this.field(name);
|
||||
if (field) { field.value = item[name] == null ? (name === 'draft_settings_json' ? '{}' : '') : item[name]; }
|
||||
}, this);
|
||||
['draft_item_markup', 'draft_wrapper_markup', 'draft_empty_markup', 'draft_css'].forEach(function (name) {
|
||||
this.setCode(name, item[name] || '');
|
||||
}, this);
|
||||
this.loading = false;
|
||||
this.needsReload = false;
|
||||
this.currentRevision = 0;
|
||||
this.setStatus(item);
|
||||
this.renderAssignments(item.assignments || []);
|
||||
this.clearRevisionView();
|
||||
this.clearPreview();
|
||||
this.clearDiagnostics();
|
||||
this.setDirty(false);
|
||||
},
|
||||
|
||||
defaultValue: function (name) {
|
||||
var field = document.querySelector('[data-presentation-default="' + name + '"]');
|
||||
return field ? field.value : '';
|
||||
},
|
||||
|
||||
setCode: function (name, value) {
|
||||
var field = this.field(name), editor = this.editor(name);
|
||||
if (field) { field.value = value || ''; }
|
||||
if (editor) { editor.setValue(value || ''); }
|
||||
},
|
||||
|
||||
sync: function () {
|
||||
if (Adminx.CodeEditor) { Adminx.CodeEditor.syncAll(this.form); }
|
||||
},
|
||||
|
||||
refreshEditors: function () {
|
||||
this.bindEditors();
|
||||
setTimeout(function () {
|
||||
if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); }
|
||||
}, 80);
|
||||
},
|
||||
|
||||
tab: function (name) {
|
||||
this.drawer.querySelectorAll('[data-presentation-tab]').forEach(function (button) {
|
||||
var active = button.getAttribute('data-presentation-tab') === name;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
this.drawer.querySelectorAll('[data-presentation-panel]').forEach(function (panel) {
|
||||
panel.hidden = panel.getAttribute('data-presentation-panel') !== name;
|
||||
});
|
||||
if (name === 'item') { this.activeEditor = 'draft_item_markup'; }
|
||||
if (name === 'wrapper') { this.activeEditor = 'draft_wrapper_markup'; }
|
||||
if (name === 'empty') { this.activeEditor = 'draft_empty_markup'; }
|
||||
if (name === 'css') { this.activeEditor = 'draft_css'; }
|
||||
if (name === 'revisions') { this.loadRevisions(); }
|
||||
this.refreshEditors();
|
||||
},
|
||||
|
||||
save: function (quiet) {
|
||||
var self = this;
|
||||
this.sync();
|
||||
var id = this.id();
|
||||
this.setSaveState('Сохранение...', '');
|
||||
return this.request(this.resource() + (id ? '/' + id : ''), {
|
||||
method: 'POST',
|
||||
body: new FormData(this.form)
|
||||
}).then(function (payload) {
|
||||
var savedId = Number((payload.data || {}).id) || id;
|
||||
self.field('id').value = savedId;
|
||||
self.needsReload = true;
|
||||
self.setDirty(false);
|
||||
self.setSaveState('Черновик сохранён', 'ok');
|
||||
if (!quiet) { Adminx.Toast.show(payload.message || 'Черновик сохранён', 'success'); }
|
||||
return savedId;
|
||||
}).catch(function (error) {
|
||||
self.setSaveState('Не сохранено', 'error');
|
||||
self.fail(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
publish: function () {
|
||||
var self = this;
|
||||
this.save(true).then(function (id) {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
return self.request(self.resource() + '/' + id + '/publish', {method: 'POST', body: data});
|
||||
}).then(function (payload) {
|
||||
self.setStatus(payload.data || {});
|
||||
self.setSaveState('Опубликовано', 'ok');
|
||||
self.needsReload = true;
|
||||
Adminx.Toast.show(payload.message || 'Представление опубликовано', 'success');
|
||||
}).catch(function () {});
|
||||
},
|
||||
|
||||
lint: function () {
|
||||
var self = this;
|
||||
this.sync();
|
||||
var data = new FormData(this.form);
|
||||
this.setLint('Проверка...', '');
|
||||
this.request(this.resource() + '/lint', {method: 'POST', body: data}).then(function (payload) {
|
||||
self.setLint(payload.message || 'Twig-синтаксис корректен', 'ok');
|
||||
}).catch(function (error) {
|
||||
self.setLint(error.message, 'error');
|
||||
});
|
||||
},
|
||||
|
||||
preview: function () {
|
||||
var self = this;
|
||||
this.sync();
|
||||
Adminx.Loader.show();
|
||||
this.request(this.resource() + '/preview', {method: 'POST', body: new FormData(this.form)}).then(function (payload) {
|
||||
var result = payload.data || {};
|
||||
self.drawer.querySelector('[data-presentation-preview-frame]').srcdoc = result.html || '';
|
||||
self.drawer.querySelector('[data-presentation-preview-json]').textContent = result.json || '{}';
|
||||
var state = self.drawer.querySelector('[data-presentation-preview-state] span');
|
||||
if (state) {
|
||||
state.textContent = result.document && result.document.title
|
||||
? 'Показан материал «' + result.document.title + '» · ID ' + result.document.id
|
||||
: 'Предпросмотр обновлён.';
|
||||
}
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
}).finally(function () {
|
||||
Adminx.Loader.hide();
|
||||
});
|
||||
},
|
||||
|
||||
clearPreview: function () {
|
||||
var frame = this.drawer.querySelector('[data-presentation-preview-frame]');
|
||||
if (frame) { frame.removeAttribute('srcdoc'); }
|
||||
var json = this.drawer.querySelector('[data-presentation-preview-json]');
|
||||
if (json) { json.textContent = 'После запуска здесь появятся данные, доступные шаблону.'; }
|
||||
var state = this.drawer.querySelector('[data-presentation-preview-state] span');
|
||||
if (state) { state.textContent = 'Выберите материал и запустите предпросмотр.'; }
|
||||
},
|
||||
|
||||
fullscreen: function (name) {
|
||||
var editor = this.editor(name);
|
||||
if (editor && Adminx.CodeEditor) { Adminx.CodeEditor.toggleFullscreen(editor); }
|
||||
},
|
||||
|
||||
insertVariable: function (value) {
|
||||
var editorName = this.activeEditor === 'draft_css' ? 'draft_item_markup' : this.activeEditor;
|
||||
var tab = {draft_item_markup: 'item', draft_wrapper_markup: 'wrapper', draft_empty_markup: 'empty'}[editorName] || 'item';
|
||||
this.tab(tab);
|
||||
var editor = this.editor(editorName);
|
||||
if (editor) {
|
||||
editor.replaceSelection(value || '');
|
||||
editor.focus();
|
||||
editor.save();
|
||||
}
|
||||
},
|
||||
|
||||
saveAssignment: function () {
|
||||
var id = this.id(), self = this;
|
||||
if (!id) {
|
||||
Adminx.Toast.show('Сначала сохраните представление', 'warning');
|
||||
return;
|
||||
}
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
data.set('context_code', this.field('assignment_context').value);
|
||||
data.set('target_type', this.field('assignment_target_type').value);
|
||||
data.set('target_key', this.field('assignment_target_key').value || '0');
|
||||
data.set('mode', this.field('assignment_mode').value);
|
||||
this.request(this.resource() + '/' + id + '/assignments', {method: 'POST', body: data}).then(function (payload) {
|
||||
self.renderAssignments((payload.data || {}).assignments || []);
|
||||
self.needsReload = true;
|
||||
Adminx.Toast.show(payload.message || 'Назначение сохранено', 'success');
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
deleteAssignment: function (id) {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'warning',
|
||||
title: 'Удалить назначение?',
|
||||
message: 'Само представление и публичный шаблон не изменятся.',
|
||||
confirmLabel: 'Удалить',
|
||||
onConfirm: function () {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
self.request(self.assignmentResource() + '/' + id + '/delete', {method: 'POST', body: data}).then(function () {
|
||||
self.reloadAssignments();
|
||||
self.needsReload = true;
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
reloadAssignments: function () {
|
||||
var self = this, id = this.id();
|
||||
if (!id) { return; }
|
||||
this.request(this.resource() + '/' + id).then(function (payload) {
|
||||
self.renderAssignments((payload.data || {}).assignments || []);
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
renderAssignments: function (items) {
|
||||
var list = this.drawer.querySelector('[data-presentation-assignment-list]');
|
||||
var contexts = {
|
||||
content_list: 'Список материалов',
|
||||
compact_list: 'Компактный список',
|
||||
slider: 'Слайдер',
|
||||
search_results: 'Результаты поиска',
|
||||
related: 'Похожие материалы',
|
||||
favorites: 'Избранное',
|
||||
viewed: 'Просмотренные материалы',
|
||||
catalog_filters: 'Фильтры каталога',
|
||||
document_page: 'Страница материала'
|
||||
};
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div class="empty-state">Назначений пока нет.</div>';
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
list.innerHTML = items.map(function (item) {
|
||||
var modeClass = item.mode === 'native' ? 'badge-green' : (item.mode === 'preview' ? 'badge-amber' : 'badge-gray');
|
||||
var targetClass = item.target_available === false ? ' is-missing' : '';
|
||||
var target = '<span class="public-presentation-assignment-target' + targetClass + '"><b>' + self.escape(item.target_title || item.target_key) + '</b><small>' + self.escape(item.target_meta || '') + '</small></span>';
|
||||
if (item.target_url) {
|
||||
target = '<a class="public-presentation-assignment-target" href="' + self.escape(self.base() + item.target_url) + '"><b>' + self.escape(item.target_title || item.target_key) + '</b><small>' + self.escape(item.target_meta || '') + '</small></a>';
|
||||
}
|
||||
return '<div class="public-presentation-assignment"><span><b>' + self.escape(contexts[item.context_code] || item.context_code) + '</b><small>' + self.escape(item.updated_label || '') + '</small></span>' + target + '<span class="badge ' + modeClass + '">' + self.escape(item.mode_label || item.mode) + '</span><button class="btn btn-ghost btn-icon btn-sm is-danger" type="button" data-presentation-assignment-delete="' + Number(item.id) + '" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>';
|
||||
}).join('');
|
||||
},
|
||||
|
||||
targetTypeChanged: function () {
|
||||
var type = this.field('assignment_target_type').value || 'global';
|
||||
var select = this.field('assignment_target_key');
|
||||
var current = select.value;
|
||||
var items = this.targetCatalog[type] || [];
|
||||
var self = this;
|
||||
select.innerHTML = items.map(function (item) {
|
||||
return '<option value="' + self.escape(item.key) + '">' + self.escape(item.title) + ' · ' + self.escape(item.meta) + '</option>';
|
||||
}).join('');
|
||||
if (!items.length) {
|
||||
select.innerHTML = '<option value="">Доступных целей нет</option>';
|
||||
select.disabled = true;
|
||||
} else {
|
||||
select.disabled = false;
|
||||
select.value = items.some(function (item) { return String(item.key) === String(current); }) ? current : items[0].key;
|
||||
}
|
||||
var hint = this.drawer.querySelector('[data-presentation-target-hint]');
|
||||
if (hint) {
|
||||
hint.textContent = type === 'global'
|
||||
? 'Общее назначение для всех подходящих мест.'
|
||||
: (items.length ? 'Выберите цель по названию. ID вводить не нужно.' : 'Сначала создайте или включите подходящую цель.');
|
||||
}
|
||||
},
|
||||
|
||||
diagnose: function () {
|
||||
var self = this;
|
||||
this.save(true).then(function (id) {
|
||||
self.sync();
|
||||
self.setDiagnosticState('Проверяем...', 'loading');
|
||||
return self.request(self.resource() + '/' + id + '/diagnose', {
|
||||
method: 'POST',
|
||||
body: new FormData(self.form)
|
||||
});
|
||||
}).then(function (payload) {
|
||||
self.renderDiagnostics(payload.data || {});
|
||||
self.tab('diagnostics');
|
||||
}).catch(function (error) {
|
||||
self.setDiagnosticState('Проверка не выполнена', 'error');
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
renderDiagnostics: function (result) {
|
||||
var summary = result.summary || {};
|
||||
var list = this.drawer.querySelector('[data-presentation-diagnostic-list]');
|
||||
var self = this;
|
||||
this.setDiagnosticState(
|
||||
result.ready
|
||||
? (Number(summary.warning || 0) > 0
|
||||
? 'Ошибок нет · предупреждений: ' + Number(summary.warning || 0)
|
||||
: 'Готово к публикации')
|
||||
: 'Ошибок: ' + Number(summary.error || 0) + ' · предупреждений: ' + Number(summary.warning || 0),
|
||||
result.ready ? (Number(summary.warning || 0) > 0 ? 'warning' : 'ok') : 'error'
|
||||
);
|
||||
list.innerHTML = (result.checks || []).map(function (check) {
|
||||
var icon = check.status === 'ok' ? 'ti-circle-check' : (check.status === 'error' ? 'ti-alert-circle' : 'ti-alert-triangle');
|
||||
var action = check.url ? '<a class="btn btn-ghost btn-sm" href="' + self.escape(self.base() + check.url) + '"><i class="ti ti-arrow-up-right"></i>Открыть</a>' : '';
|
||||
return '<article class="public-presentation-diagnostic is-' + self.escape(check.status) + '"><i class="ti ' + icon + '"></i><span><b>' + self.escape(check.title) + '</b><small>' + self.escape(check.message) + '</small></span>' + action + '</article>';
|
||||
}).join('') || '<div class="empty-state">Результатов проверки нет.</div>';
|
||||
},
|
||||
|
||||
clearDiagnostics: function () {
|
||||
this.setDiagnosticState('Проверка ещё не запускалась', '');
|
||||
var list = this.drawer.querySelector('[data-presentation-diagnostic-list]');
|
||||
if (list) { list.innerHTML = '<div class="empty-state">Сохраните представление и запустите диагностику.</div>'; }
|
||||
},
|
||||
|
||||
setDiagnosticState: function (message, state) {
|
||||
var summary = this.drawer.querySelector('[data-presentation-diagnostic-summary]');
|
||||
if (!summary) { return; }
|
||||
var badge = state === 'ok'
|
||||
? 'badge-green'
|
||||
: (state === 'warning' ? 'badge-amber' : (state === 'error' ? 'badge-red' : (state === 'loading' ? 'badge-blue' : 'badge-gray')));
|
||||
summary.innerHTML = '<span class="badge ' + badge + '">' + this.escape(message || '') + '</span>';
|
||||
},
|
||||
|
||||
copy: function (id) {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'info',
|
||||
title: 'Создать копию?',
|
||||
message: 'Будет создан новый неопубликованный черновик без назначений.',
|
||||
confirmLabel: 'Создать копию',
|
||||
onConfirm: function () {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
self.request(self.resource() + '/' + id + '/copy', {method: 'POST', body: data}).then(function () {
|
||||
window.location.reload();
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
remove: function (id) {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'error',
|
||||
title: 'Удалить представление?',
|
||||
message: 'Черновик, опубликованная версия и ревизии будут удалены. Назначенное представление удалить нельзя.',
|
||||
confirmLabel: 'Удалить',
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: function () {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
self.request(self.resource() + '/' + id + '/delete', {method: 'POST', body: data}).then(function () {
|
||||
window.location.reload();
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadRevisions: function () {
|
||||
var id = this.id(), self = this;
|
||||
var list = this.drawer.querySelector('[data-presentation-revision-list]');
|
||||
if (!id) {
|
||||
list.innerHTML = '<div class="empty-state">Сначала сохраните представление.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '<div class="empty-state">Загрузка...</div>';
|
||||
this.request(this.resource() + '/' + id + '/revisions').then(function (payload) {
|
||||
var items = (payload.data || {}).revisions || [];
|
||||
self.drawer.querySelector('[data-presentation-revisions-count]').textContent = items.length ? ('Снимков: ' + items.length) : 'Снимков пока нет.';
|
||||
self.drawer.querySelector('[data-presentation-revisions-clear]').disabled = !items.length;
|
||||
list.innerHTML = items.length ? items.map(function (item) {
|
||||
return '<button type="button" data-presentation-revision="' + Number(item.id) + '"><span><b>' + self.escape(item.action_label) + '</b><small>' + self.escape(item.comment || item.author_name || '') + '</small></span><span><em class="badge ' + self.escape(item.badge) + '">' + self.escape(item.action_label) + '</em><small class="mono">' + self.escape(item.created_label) + '</small></span></button>';
|
||||
}).join('') : '<div class="empty-state">Ревизий пока нет.</div>';
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
showRevision: function (id) {
|
||||
var self = this;
|
||||
this.request(this.revisionResource() + '/' + id).then(function (payload) {
|
||||
var item = payload.data || {}, snapshot = item.snapshot || {};
|
||||
self.currentRevision = Number(item.id) || 0;
|
||||
self.drawer.querySelectorAll('[data-presentation-revision]').forEach(function (button) {
|
||||
button.classList.toggle('is-active', Number(button.getAttribute('data-presentation-revision')) === self.currentRevision);
|
||||
});
|
||||
self.drawer.querySelector('[data-presentation-revision-meta]').innerHTML = '<b>' + self.escape(item.action_label || '') + '</b><small>' + self.escape(item.created_label || '') + ' · ' + self.escape(item.author_name || 'system') + '</small>';
|
||||
self.drawer.querySelector('[data-presentation-revision-code]').textContent = snapshot.draft_item_markup || '';
|
||||
self.drawer.querySelector('[data-presentation-revision-restore]').disabled = false;
|
||||
self.drawer.querySelector('[data-presentation-revision-delete]').disabled = false;
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
restoreRevision: function () {
|
||||
if (!this.currentRevision) { return; }
|
||||
var self = this, revision = this.currentRevision;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'warning',
|
||||
title: 'Восстановить черновик?',
|
||||
message: 'Опубликованная версия публичного сайта не изменится.',
|
||||
confirmLabel: 'Восстановить',
|
||||
onConfirm: function () {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
self.request(self.revisionResource() + '/' + revision + '/restore', {method: 'POST', body: data}).then(function () {
|
||||
self.needsReload = true;
|
||||
self.open(self.id(), 'revisions');
|
||||
Adminx.Toast.show('Ревизия восстановлена в черновик', 'success');
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteRevision: function () {
|
||||
if (!this.currentRevision) { return; }
|
||||
var self = this, data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
this.request(this.revisionResource() + '/' + this.currentRevision + '/delete', {method: 'POST', body: data}).then(function () {
|
||||
self.clearRevisionView();
|
||||
self.loadRevisions();
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
},
|
||||
|
||||
clearRevisions: function () {
|
||||
var self = this, id = this.id();
|
||||
if (!id) { return; }
|
||||
Adminx.Confirm.open({
|
||||
kind: 'error',
|
||||
title: 'Удалить все ревизии?',
|
||||
message: 'Текущий черновик и опубликованная версия сохранятся.',
|
||||
confirmLabel: 'Удалить все',
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: function () {
|
||||
var data = new FormData();
|
||||
data.set('_csrf', Adminx.csrf());
|
||||
self.request(self.resource() + '/' + id + '/revisions/delete', {method: 'POST', body: data}).then(function () {
|
||||
self.clearRevisionView();
|
||||
self.loadRevisions();
|
||||
}).catch(function (error) {
|
||||
self.fail(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearRevisionView: function () {
|
||||
this.currentRevision = 0;
|
||||
var meta = this.drawer.querySelector('[data-presentation-revision-meta]');
|
||||
var code = this.drawer.querySelector('[data-presentation-revision-code]');
|
||||
if (meta) { meta.textContent = 'Выберите снимок выше.'; }
|
||||
if (code) { code.textContent = ''; }
|
||||
this.drawer.querySelectorAll('[data-presentation-revision-restore],[data-presentation-revision-delete]').forEach(function (button) {
|
||||
button.disabled = true;
|
||||
});
|
||||
},
|
||||
|
||||
setStatus: function (item) {
|
||||
var badge = this.drawer.querySelector('[data-presentation-status]');
|
||||
badge.textContent = item.is_published ? ('Опубликовано · v' + (item.version || 1)) : 'Черновик';
|
||||
badge.className = 'badge ' + (item.is_published ? (item.has_changes ? 'badge-amber' : 'badge-green') : 'badge-gray');
|
||||
this.drawer.querySelector('[data-presentation-title]').textContent = item.title || this.field('title').value || 'Новое представление';
|
||||
},
|
||||
|
||||
setDirty: function (value) {
|
||||
this.dirty = !!value;
|
||||
this.drawer.classList.toggle('has-unsaved-changes', this.dirty);
|
||||
if (this.dirty) { this.setSaveState('Есть несохранённые изменения', 'dirty'); }
|
||||
},
|
||||
|
||||
setSaveState: function (message, state) {
|
||||
var element = this.drawer.querySelector('[data-presentation-save-state]');
|
||||
element.textContent = message || '';
|
||||
element.className = 'public-presentation-save-state' + (state ? ' is-' + state : '');
|
||||
},
|
||||
|
||||
setLint: function (message, state) {
|
||||
this.drawer.querySelectorAll('[data-presentation-lint-result]').forEach(function (element) {
|
||||
element.textContent = message || '';
|
||||
element.className = 'field-hint' + (state ? ' is-' + state : '');
|
||||
});
|
||||
},
|
||||
|
||||
confirmDiscard: function () {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'warning',
|
||||
title: 'Закрыть без сохранения?',
|
||||
message: 'Изменения текущего черновика будут потеряны.',
|
||||
confirmLabel: 'Закрыть',
|
||||
onConfirm: function () {
|
||||
self.setDirty(false);
|
||||
if (self.needsReload) { window.location.reload(); return; }
|
||||
Adminx.Drawer.close(self.drawer);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
readJson: function (selector) {
|
||||
var node = document.querySelector(selector);
|
||||
if (!node) { return {}; }
|
||||
try { return JSON.parse(node.textContent || '{}'); }
|
||||
catch (error) { return {}; }
|
||||
},
|
||||
|
||||
escape: function (value) {
|
||||
var node = document.createElement('div');
|
||||
node.textContent = value == null ? '' : String(value);
|
||||
return node.innerHTML;
|
||||
},
|
||||
|
||||
fail: function (error) {
|
||||
Adminx.Toast.show((error && error.message) || 'Не удалось выполнить действие', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { Adminx.PublicPresentations.init(); });
|
||||
} else {
|
||||
Adminx.PublicPresentations.init();
|
||||
}
|
||||
})(window, document);
|
||||
@@ -0,0 +1,834 @@
|
||||
.public-site-tabs {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.public-site-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.public-site-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
.public-site-stat b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.public-site-stat span:not(.icon-tile) {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-panel-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.public-site-placement-notice,
|
||||
.public-site-placement-help {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.public-site-placement-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-placement-view-tabs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.public-site-structure {
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.public-site-placement-filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.public-site-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-card-title h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.public-site-card-title p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-filter .input-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
.public-site-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-main-card .table-scroll {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
.public-site-col-type {
|
||||
width: 25%;
|
||||
}
|
||||
.public-site-col-template {
|
||||
width: 20%;
|
||||
}
|
||||
.public-site-col-numbers {
|
||||
width: 22%;
|
||||
}
|
||||
.public-site-col-state {
|
||||
width: 27%;
|
||||
}
|
||||
.public-site-col-actions {
|
||||
width: 76px;
|
||||
}
|
||||
.public-site-name,
|
||||
.public-site-template {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.public-site-name span,
|
||||
.public-site-template span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-counts b {
|
||||
color: var(--text);
|
||||
}
|
||||
.public-site-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.public-site-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.public-site-support-panel,
|
||||
.public-site-editors-panel {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.public-site-diagnostics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
background: var(--border);
|
||||
}
|
||||
.public-site-diagnostic {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-site-diagnostic > i:first-child {
|
||||
color: var(--amber-600);
|
||||
font-size: 18px;
|
||||
}
|
||||
.public-site-diagnostic > i:last-child {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
.public-site-diagnostic span {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-diagnostic small {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.public-site-editor-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.public-site-editor-links > a {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-site-editor-links > a:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.public-site-editor-links > a:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-editor-links > a > i:first-child {
|
||||
color: var(--blue-600);
|
||||
font-size: 20px;
|
||||
}
|
||||
.public-site-editor-links > a > i:last-child {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
.public-site-editor-links > a span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-editor-links > a small {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.public-site-diagnostic:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-diagnostic-error > i:first-child {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-site-empty {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 120px;
|
||||
}
|
||||
.public-site-empty i {
|
||||
color: var(--green-600);
|
||||
font-size: 28px;
|
||||
}
|
||||
.public-site-placement-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) repeat(3, minmax(150px, 190px)) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.public-site-placement-search {
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-placement-scroll {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
.public-site-placement-col-area {
|
||||
width: 17%;
|
||||
}
|
||||
.public-site-placement-col-source,
|
||||
.public-site-placement-col-component {
|
||||
width: 29%;
|
||||
}
|
||||
.public-site-placement-col-state {
|
||||
width: 18%;
|
||||
}
|
||||
.public-site-placement-col-action {
|
||||
width: 88px;
|
||||
}
|
||||
.public-site-placement-area,
|
||||
.public-site-placement-source,
|
||||
.public-site-placement-component {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-placement-area small,
|
||||
.public-site-placement-source small,
|
||||
.public-site-placement-component small,
|
||||
.public-site-placement-area span,
|
||||
.public-site-placement-source span,
|
||||
.public-site-placement-component span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.public-site-placement-area {
|
||||
justify-items: start;
|
||||
}
|
||||
.public-site-placement-component code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-placement-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.public-site-placement-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.public-site-placement-footer .pagination {
|
||||
padding-top: 0;
|
||||
}
|
||||
.public-site-usage-table th:nth-child(3),
|
||||
.public-site-usage-table th:nth-child(4),
|
||||
.public-site-usage-table td:nth-child(3),
|
||||
.public-site-usage-table td:nth-child(4) {
|
||||
width: 110px;
|
||||
}
|
||||
.public-site-usage-table th:last-child,
|
||||
.public-site-usage-table td:last-child {
|
||||
width: 56px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.public-site-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-diagnostics,
|
||||
.public-site-editor-links {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-editor-links > a:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
.public-site-editor-links > a:nth-child(-n + 2) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-placement-filter {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-placement-filter .public-site-placement-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.public-site-summary,
|
||||
.public-site-diagnostics,
|
||||
.public-site-editor-links {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-page-head,
|
||||
.public-site-filter {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.public-site-page-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.public-site-page-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
.public-site-editor-links > a,
|
||||
.public-site-editor-links > a:nth-child(2) {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-editor-links > a:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.public-site-placement-summary,
|
||||
.public-site-placement-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-placement-filter .public-site-placement-search {
|
||||
grid-column: auto;
|
||||
}
|
||||
.public-site-placement-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.public-site-main-card .table-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.public-site-main-card .public-site-table {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-main-card .public-site-table colgroup,
|
||||
.public-site-main-card .public-site-table thead {
|
||||
display: none;
|
||||
}
|
||||
.public-site-main-card .public-site-table tbody,
|
||||
.public-site-main-card .public-site-table tr,
|
||||
.public-site-main-card .public-site-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.public-site-main-card .public-site-table tr {
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-main-card .public-site-table td {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 30%) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
}
|
||||
.public-site-main-card .public-site-table td::before {
|
||||
color: var(--text-secondary);
|
||||
content: attr(data-label);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-main-card .public-site-table .public-site-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.public-site-main-card .public-site-table .public-site-state {
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-main-card .public-site-table .public-site-state .badge {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.public-site-placement-card .table-scroll,
|
||||
.public-site-usage-card .table-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.public-site-placement-card table.public-site-table,
|
||||
.public-site-usage-card table.public-site-table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-placement-card table.public-site-table colgroup,
|
||||
.public-site-usage-card table.public-site-table colgroup,
|
||||
.public-site-placement-card table.public-site-table thead,
|
||||
.public-site-usage-card table.public-site-table thead {
|
||||
display: none;
|
||||
}
|
||||
.public-site-placement-card table.public-site-table tbody,
|
||||
.public-site-usage-card table.public-site-table tbody,
|
||||
.public-site-placement-card table.public-site-table tr,
|
||||
.public-site-usage-card table.public-site-table tr,
|
||||
.public-site-placement-card table.public-site-table td,
|
||||
.public-site-usage-card table.public-site-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.public-site-placement-card table.public-site-table tr,
|
||||
.public-site-usage-card table.public-site-table tr {
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.public-site-placement-card table.public-site-table td,
|
||||
.public-site-usage-card table.public-site-table td {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 30%) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
}
|
||||
.public-site-placement-card table.public-site-table td::before,
|
||||
.public-site-usage-card table.public-site-table td::before {
|
||||
color: var(--text-secondary);
|
||||
content: attr(data-label);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-placement-card .public-site-placement-actions,
|
||||
.public-site-usage-card .public-site-placement-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
.public-site-map-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.public-site-map-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
.public-site-map-filter .input-wrap {
|
||||
flex: 1 1 320px;
|
||||
max-width: 620px;
|
||||
}
|
||||
.public-site-map-menus {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.public-site-map-menu {
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-map-menu > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.public-site-map-menu h3,
|
||||
.public-site-map-menu p {
|
||||
margin: 0;
|
||||
}
|
||||
.public-site-map-menu p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-map-tree {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.public-site-map-tree .public-site-map-tree {
|
||||
margin-left: 21px;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border-default);
|
||||
}
|
||||
.public-site-map-node {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 5px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.public-site-map-node.has-action {
|
||||
grid-template-columns: 32px minmax(0, 1fr) 40px;
|
||||
}
|
||||
.public-site-map-node:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-map-node .icon-tile {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.public-site-map-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-map-copy b,
|
||||
.public-site-map-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.public-site-map-copy small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-map-tree li.is-muted > .public-site-map-node {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.public-site-orphans-panel {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.public-site-orphans {
|
||||
display: grid;
|
||||
}
|
||||
.public-site-orphans > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 52px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-site-orphans > a + a {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.public-site-orphans > a:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-orphans > a > span:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-orphans small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-inspector-form form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.public-site-inspector-form form .input-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
.public-site-inspector-message {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.public-site-inspector-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.public-site-inspector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.public-site-findings,
|
||||
.public-site-inspector-list,
|
||||
.public-site-base-checks {
|
||||
display: grid;
|
||||
}
|
||||
.public-site-finding {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 36px;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 68px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.public-site-finding + .public-site-finding {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.public-site-finding > i {
|
||||
font-size: 21px;
|
||||
}
|
||||
.public-site-finding.is-error > i {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-site-finding.is-warning > i {
|
||||
color: var(--amber-600);
|
||||
}
|
||||
.public-site-finding.is-info > i {
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.public-site-finding.is-ok > i {
|
||||
color: var(--green-600);
|
||||
}
|
||||
.public-site-finding p {
|
||||
margin: 3px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-finding small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-inspector-list > a,
|
||||
.public-site-inspector-list > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 58px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.public-site-inspector-list > a + a,
|
||||
.public-site-inspector-list > div + a,
|
||||
.public-site-inspector-list > a + div,
|
||||
.public-site-inspector-list > div + div {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.public-site-inspector-list > a > span:first-child,
|
||||
.public-site-inspector-list > div > span:first-child {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-site-inspector-list > a small,
|
||||
.public-site-inspector-list > div small {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.public-site-inspector-list > a:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-json {
|
||||
margin-top: var(--space-5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.public-site-json summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.public-site-json pre {
|
||||
max-height: 520px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.public-site-field-value {
|
||||
max-width: 520px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.public-site-field-value code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.public-site-request-inspector {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.public-site-request-form form {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 150px minmax(260px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.public-site-request-form form label {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.public-site-request-form form label > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.public-site-base-checks {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
.public-site-base-checks > div {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface);
|
||||
}
|
||||
.public-site-base-checks > div > i {
|
||||
font-size: 18px;
|
||||
}
|
||||
.public-site-base-checks > div > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-site-base-checks > div small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-base-checks > div.is-passed > i {
|
||||
color: var(--green-600);
|
||||
}
|
||||
.public-site-base-checks > div.is-failed > i {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.public-site-condition-tree {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.public-site-condition-group {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.public-site-condition-group .public-site-condition-group {
|
||||
margin-left: var(--space-4);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.public-site-condition-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.public-site-condition-head > span:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.public-site-condition {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
.public-site-condition > i {
|
||||
font-size: 18px;
|
||||
}
|
||||
.public-site-condition > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.public-site-condition span,
|
||||
.public-site-condition small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.public-site-condition.is-passed > i {
|
||||
color: var(--green-600);
|
||||
}
|
||||
.public-site-condition.is-failed > i {
|
||||
color: var(--red-600);
|
||||
}
|
||||
@media (max-width: 920px) {
|
||||
.public-site-map-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-map-menus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-inspector-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-request-form form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.public-site-request-parameters {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.public-site-map-summary,
|
||||
.public-site-map-menus,
|
||||
.public-site-inspector-summary,
|
||||
.public-site-base-checks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-map-menus {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.public-site-map-tree .public-site-map-tree {
|
||||
margin-left: 16px;
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
.public-site-orphans > a {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.public-site-inspector-form form {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.public-site-request-form form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.public-site-request-parameters {
|
||||
grid-column: auto;
|
||||
}
|
||||
.public-site-condition-group .public-site-condition-group {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="public_site_catalog_filters">Catalog filters</phrase>
|
||||
<phrase data="public_site_choose_material">Choose a material</phrase>
|
||||
<phrase data="public_site_choose_snapshot">Choose a snapshot above.</phrase>
|
||||
<phrase data="public_site_compact_content_list">Compact list</phrase>
|
||||
<phrase data="public_site_compact_list">Compact list</phrase>
|
||||
<phrase data="public_site_content_list">Material list</phrase>
|
||||
<phrase data="public_site_diag_failed_state">failed</phrase>
|
||||
<phrase data="public_site_diag_passed">passed</phrase>
|
||||
<phrase data="public_site_diag_results">Inspection results</phrase>
|
||||
<phrase data="public_site_document_page">Material page</phrase>
|
||||
<phrase data="public_site_favorite">Favorites</phrase>
|
||||
<phrase data="public_site_items_lower">materials</phrase>
|
||||
<phrase data="public_site_new_presentation">New presentation</phrase>
|
||||
<phrase data="public_site_no_snapshots">No snapshots yet.</phrase>
|
||||
<phrase data="public_site_preview_data_hint">Data available to the template will appear here after preview.</phrase>
|
||||
<phrase data="public_site_related_items">Related materials</phrase>
|
||||
<phrase data="public_site_search_results">Search results</phrase>
|
||||
<phrase data="public_site_version_lower">version</phrase>
|
||||
</language>
|
||||
@@ -0,0 +1,215 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="nav_public_site">Public site</phrase>
|
||||
<phrase data="public_site_permission">Public site: view structure</phrase>
|
||||
<phrase data="public_site_permission_description">View links between content types, documents, requests, catalogs and templates.</phrase>
|
||||
<phrase data="public_site_intro">A map of content, collections, catalogs and presentation links.</phrase>
|
||||
<phrase data="public_site_content_types">Content types</phrase>
|
||||
<phrase data="public_site_materials">Materials</phrase>
|
||||
<phrase data="public_site_collections">Collections</phrase>
|
||||
<phrase data="public_site_catalogs">Catalogs</phrase>
|
||||
<phrase data="public_site_components">Blocks and menus</phrase>
|
||||
<phrase data="public_site_product_presentations">Product presentations</phrase>
|
||||
<phrase data="public_site_stage_one">First stage</phrase>
|
||||
<phrase data="public_site_structure_map">Structure map</phrase>
|
||||
<phrase data="public_site_safe_map">Nothing is switched on the public site: this map shows existing entities and links to their standard editors.</phrase>
|
||||
<phrase data="public_site_content_types_title">Content types</phrase>
|
||||
<phrase data="public_site_content_type">Content type</phrase>
|
||||
<phrase data="public_site_current_list">in the current list</phrase>
|
||||
<phrase data="public_site_open_rubrics">Open content types</phrase>
|
||||
<phrase data="public_site_search_placeholder">Title, alias or ID</phrase>
|
||||
<phrase data="public_site_show">Show</phrase>
|
||||
<phrase data="public_site_reset">Reset</phrase>
|
||||
<phrase data="public_site_site_template">Site template</phrase>
|
||||
<phrase data="public_site_composition">Composition</phrase>
|
||||
<phrase data="public_site_state">State</phrase>
|
||||
<phrase data="public_site_actions">Actions</phrase>
|
||||
<phrase data="public_site_alias_missing">alias is not set</phrase>
|
||||
<phrase data="public_site_not_assigned">not assigned</phrase>
|
||||
<phrase data="public_site_items_lower">materials</phrase>
|
||||
<phrase data="public_site_fields_lower">fields</phrase>
|
||||
<phrase data="public_site_collections_lower">collections</phrase>
|
||||
<phrase data="public_site_catalogs_lower">catalogs</phrase>
|
||||
<phrase data="public_site_catalogs_link">Catalogs</phrase>
|
||||
<phrase data="public_site_creation_disabled">creation disabled</phrase>
|
||||
<phrase data="public_site_configured">configured</phrase>
|
||||
<phrase data="public_site_content_link">Materials</phrase>
|
||||
<phrase data="public_site_type_settings">Content type settings</phrase>
|
||||
<phrase data="public_site_not_found">No content types found.</phrase>
|
||||
<phrase data="public_site_diagnostics">Diagnostics</phrase>
|
||||
<phrase data="public_site_hints_only">Hints only, without automatic changes</phrase>
|
||||
<phrase data="public_site_no_issues">No obvious problems were found on the current map.</phrase>
|
||||
<phrase data="public_site_preview_renderers">Collection preview</phrase>
|
||||
<phrase data="public_site_preview_description">Ways to verify results without changing the public site</phrase>
|
||||
<phrase data="public_site_unavailable">unavailable</phrase>
|
||||
<phrase data="public_site_presentation_data">Presentation data</phrase>
|
||||
<phrase data="public_site_palette">Shared palette provided by the core and enabled modules</phrase>
|
||||
<phrase data="public_site_classic_editors">Classic editors</phrase>
|
||||
<phrase data="public_site_classic_remain">Familiar sections remain the primary editors</phrase>
|
||||
<phrase data="public_site_requests">Requests and conditions</phrase>
|
||||
<phrase data="public_site_blocks">Blocks</phrase>
|
||||
<phrase data="public_site_reusable_fragments">Reusable fragments</phrase>
|
||||
<phrase data="public_site_navigation">Navigation</phrase>
|
||||
<phrase data="public_site_menu_tree">Menus and item tree</phrase>
|
||||
<phrase data="public_site_sections_filters">Sections and filters</phrase>
|
||||
<phrase data="public_site_missing_request_item">The collection item template is empty</phrase>
|
||||
<phrase data="public_site_missing_site_template">An existing site template is not assigned</phrase>
|
||||
<phrase data="public_site_empty_rubric_template">The main content type template is empty</phrase>
|
||||
<phrase data="public_site_no_fields">The content type has no fields</phrase>
|
||||
<phrase data="public_site_material_group">Material</phrase>
|
||||
<phrase data="public_site_context_group">Context</phrase>
|
||||
<phrase data="public_site_product_group">Product</phrase>
|
||||
<phrase data="public_site_actions_group">Actions</phrase>
|
||||
<phrase data="public_site_document_id">Material ID</phrase>
|
||||
<phrase data="public_site_title">Title</phrase>
|
||||
<phrase data="public_site_url">URL</phrase>
|
||||
<phrase data="public_site_excerpt">Excerpt</phrase>
|
||||
<phrase data="public_site_content_fields">Content type fields</phrase>
|
||||
<phrase data="public_site_context_code">Usage context</phrase>
|
||||
<phrase data="public_site_context_position">List position</phrase>
|
||||
<phrase data="public_site_article">Article</phrase>
|
||||
<phrase data="public_site_price">Price</phrase>
|
||||
<phrase data="public_site_old_price">Old price</phrase>
|
||||
<phrase data="public_site_stock">Stock</phrase>
|
||||
<phrase data="public_site_images">Images</phrase>
|
||||
<phrase data="public_site_badges">Badges</phrase>
|
||||
<phrase data="public_site_variants">Variants</phrase>
|
||||
<phrase data="public_site_shipping_packages">Shipping packages</phrase>
|
||||
<phrase data="public_site_add_to_cart">Add to cart</phrase>
|
||||
<phrase data="public_site_favorite">Favorites</phrase>
|
||||
<phrase data="public_site_compare">Compare</phrase>
|
||||
<phrase data="public_site_legacy_description">Current main/item templates and compatible public output.</phrase>
|
||||
<phrase data="public_site_data_cards">Data cards</phrase>
|
||||
<phrase data="public_site_data_cards_description">Readable preview of properties from the result contract.</phrase>
|
||||
<phrase data="public_site_compact_list">Compact list</phrase>
|
||||
<phrase data="public_site_compact_list_description">Title and primary values on one line.</phrase>
|
||||
<phrase data="public_site_table">Table</phrase>
|
||||
<phrase data="public_site_table_description">Materials in rows with selected data in columns.</phrase>
|
||||
<phrase data="public_site_json_description">Structured result for APIs and integrations.</phrase>
|
||||
<phrase data="public_site_help_title">Public site</phrase>
|
||||
<phrase data="public_site_help_description">Shows links between content types, materials, collections, catalogs and presentations in one place.</phrase>
|
||||
<phrase data="public_site_help_map">The map does not switch anything on the public site and links to existing editors.</phrase>
|
||||
<phrase data="public_site_help_attributes">Content type fields remain universal content storage; attributes supplement them for filters and variants.</phrase>
|
||||
<phrase data="public_site_help_diagnostics">Diagnostics only reports clear gaps in required templates.</phrase>
|
||||
<phrase data="public_site_help_note">Classic sections remain a complete way to configure the site.</phrase>
|
||||
<phrase data="public_site_sections_label">Public site sections</phrase>
|
||||
<phrase data="public_site_presentations">Presentations</phrase>
|
||||
<phrase data="public_site_presentations_intro">Reusable presentation for materials, collections and catalogs.</phrase>
|
||||
<phrase data="public_site_new_presentation">New presentation</phrase>
|
||||
<phrase data="public_site_storage_missing">Storage has not been created yet</phrase>
|
||||
<phrase data="public_site_apply_migration">Apply the Public site section migration.</phrase>
|
||||
<phrase data="public_site_content_presentation">Content presentation</phrase>
|
||||
<phrase data="public_site_shared_presentations">Shared presentations</phrase>
|
||||
<phrase data="public_site_draft_safe">Draft and preview do not change the current public site.</phrase>
|
||||
<phrase data="public_site_presentation_kind">Presentation kind</phrase>
|
||||
<phrase data="public_site_all_kinds">All kinds</phrase>
|
||||
<phrase data="public_site_unpublished">Not published</phrase>
|
||||
<phrase data="public_site_version_lower">version</phrase>
|
||||
<phrase data="public_site_no_presentations">No presentations yet.</phrase>
|
||||
<phrase data="public_site_compatibility">Compatibility</phrase>
|
||||
<phrase data="public_site_current_editors">Current editors</phrase>
|
||||
<phrase data="public_site_current_editors_hint">They keep managing the current output and are not copied automatically.</phrase>
|
||||
<phrase data="public_site_ctrl_save">Ctrl/Cmd+S saves the draft and keeps the editor open.</phrase>
|
||||
<phrase data="public_site_stable_assignment_name">Stable name used by assignments.</phrase>
|
||||
<phrase data="public_site_purpose_example">For example: a material card in the news list</phrase>
|
||||
<phrase data="public_site_presentation_editor">Presentation editor</phrase>
|
||||
<phrase data="public_site_wrapper">Wrapper</phrase>
|
||||
<phrase data="public_site_empty_result">Empty result</phrase>
|
||||
<phrase data="public_site_assignments">Assignments</phrase>
|
||||
<phrase data="public_site_item_template">Item template</phrase>
|
||||
<phrase data="public_site_item_available">One material is available as item.</phrase>
|
||||
<phrase data="public_site_list_wrapper">List wrapper</phrase>
|
||||
<phrase data="public_site_wrapper_hint">Rendered elements are available in content and source data in items.</phrase>
|
||||
<phrase data="public_site_empty_hint">Shown when the collection returns no materials.</phrase>
|
||||
<phrase data="public_site_presentation_css">Presentation CSS</phrase>
|
||||
<phrase data="public_site_css_hint">Styles apply only to this presentation.</phrase>
|
||||
<phrase data="public_site_available_data">Available data</phrase>
|
||||
<phrase data="public_site_insert_data_hint">Click a value to insert it into the item template.</phrase>
|
||||
<phrase data="public_site_real_preview">Preview with a real material</phrase>
|
||||
<phrase data="public_site_preview_safe">Only the draft is used; the public site does not change.</phrase>
|
||||
<phrase data="public_site_choose_material">Choose a material</phrase>
|
||||
<phrase data="public_site_preview_data_hint">Data available to the template will appear here after preview.</phrase>
|
||||
<phrase data="public_site_where_to_use">Where to use</phrase>
|
||||
<phrase data="public_site_legacy_mode_hint">Current output mode only records the link and switches nothing.</phrase>
|
||||
<phrase data="public_site_content_list">Material list</phrase>
|
||||
<phrase data="public_site_compact_content_list">Compact list</phrase>
|
||||
<phrase data="public_site_search_results">Search results</phrase>
|
||||
<phrase data="public_site_related_items">Related materials</phrase>
|
||||
<phrase data="public_site_catalog_filters">Catalog filters</phrase>
|
||||
<phrase data="public_site_document_page">Material page</phrase>
|
||||
<phrase data="public_site_apply_to">Apply to</phrase>
|
||||
<phrase data="public_site_target_code">Target ID or code</phrase>
|
||||
<phrase data="public_site_assign">Assign</phrase>
|
||||
<phrase data="public_site_change_history">Change history</phrase>
|
||||
<phrase data="public_site_no_snapshots">No snapshots yet.</phrase>
|
||||
<phrase data="public_site_choose_snapshot">Choose a snapshot above.</phrase>
|
||||
<phrase data="public_site_place">Place</phrase>
|
||||
<phrase data="public_site_everywhere">Everywhere</phrase>
|
||||
<phrase data="public_site_to_content_type">Content type</phrase>
|
||||
<phrase data="public_site_to_collection">Collection</phrase>
|
||||
<phrase data="public_site_to_catalog">Catalog</phrase>
|
||||
<phrase data="public_site_to_module">Module</phrase>
|
||||
<phrase data="public_site_diag_page_title">Public page diagnostics</phrase>
|
||||
<phrase data="public_site_diag_page_intro">Shows how the page is assembled and why a document appears in a collection. Nothing is saved or switched.</phrase>
|
||||
<phrase data="public_site_diag_page_context">Page context</phrase>
|
||||
<phrase data="public_site_diag_page_composition">How the page is assembled</phrase>
|
||||
<phrase data="public_site_diag_page_hint">Enter a public URL or document ID. The inspection reads saved data without executing the page.</phrase>
|
||||
<phrase data="public_site_diag_inspect">Inspect page</phrase>
|
||||
<phrase data="public_site_diag_failed">Page inspection failed</phrase>
|
||||
<phrase data="public_site_diag_results">Inspection results</phrase>
|
||||
<phrase data="public_site_diag_open_document">Open document</phrase>
|
||||
<phrase data="public_site_diag_open_setting">Open setting</phrase>
|
||||
<phrase data="public_site_diag_render_chain">Render chain</phrase>
|
||||
<phrase data="public_site_diag_templates">Templates</phrase>
|
||||
<phrase data="public_site_diag_characters">characters</phrase>
|
||||
<phrase data="public_site_diag_document_cache">Document cache</phrase>
|
||||
<phrase data="public_site_diag_full_page">Full page</phrase>
|
||||
<phrase data="public_site_diag_document_template">Document template</phrase>
|
||||
<phrase data="public_site_diag_not_created">not created</phrase>
|
||||
<phrase data="public_site_diag_dependencies">Dependencies</phrase>
|
||||
<phrase data="public_site_diag_connected_components">Connected components</phrase>
|
||||
<phrase data="public_site_diag_components_hint">Blocks, collections, navigation, and modules found in the active template chain.</phrase>
|
||||
<phrase data="public_site_diag_where_connected">Connected from</phrase>
|
||||
<phrase data="public_site_diag_open_component">Open component</phrase>
|
||||
<phrase data="public_site_diag_no_components">No components found in templates</phrase>
|
||||
<phrase data="public_site_diag_document_schema">Document schema</phrase>
|
||||
<phrase data="public_site_diag_fields_values">Fields and actual values</phrase>
|
||||
<phrase data="public_site_diag_fields_hint">All category fields are shown, including empty and required fields.</phrase>
|
||||
<phrase data="public_site_diag_field">Field</phrase>
|
||||
<phrase data="public_site_diag_type">Type</phrase>
|
||||
<phrase data="public_site_diag_value">Value</phrase>
|
||||
<phrase data="public_site_diag_required_empty">required value is missing</phrase>
|
||||
<phrase data="public_site_diag_empty">empty</phrase>
|
||||
<phrase data="public_site_diag_filled">filled</phrase>
|
||||
<phrase data="public_site_diag_no_fields">The category has no fields</phrase>
|
||||
<phrase data="public_site_diag_rubric_collections">Category collections</phrase>
|
||||
<phrase data="public_site_diag_no_alias">no alias</phrase>
|
||||
<phrase data="public_site_diag_no_requests">No requests for this category</phrase>
|
||||
<phrase data="public_site_diag_direct_module_hint">The document may be used directly or by a module.</phrase>
|
||||
<phrase data="public_site_diag_result_design">Result presentation</phrase>
|
||||
<phrase data="public_site_diag_no_presentations">No presentations assigned</phrase>
|
||||
<phrase data="public_site_diag_standard_templates">The page uses standard AVE.cms templates.</phrase>
|
||||
<phrase data="public_site_diag_structured_page">Structured page package</phrase>
|
||||
<phrase data="public_site_diag_why_in_request">Why the document is in the request</phrase>
|
||||
<phrase data="public_site_diag_parameters_hint">Parameters can be entered as</phrase>
|
||||
<phrase data="public_site_diag_or_json">or a JSON object.</phrase>
|
||||
<phrase data="public_site_diag_request_id">Request ID</phrase>
|
||||
<phrase data="public_site_diag_document_id">Document ID</phrase>
|
||||
<phrase data="public_site_diag_page_parameters">Page parameters</phrase>
|
||||
<phrase data="public_site_diag_explain">Explain</phrase>
|
||||
<phrase data="public_site_diag_request_failed">Request inspection failed</phrase>
|
||||
<phrase data="public_site_diag_check_result">Inspection result</phrase>
|
||||
<phrase data="public_site_diag_document_included">Document is included in the result</phrase>
|
||||
<phrase data="public_site_diag_document_excluded">Document is excluded from the result</phrase>
|
||||
<phrase data="public_site_diag_found">found</phrase>
|
||||
<phrase data="public_site_diag_excluded">excluded</phrase>
|
||||
<phrase data="public_site_diag_structured_request">Structured request explanation</phrase>
|
||||
<phrase data="public_site_diag_passed">passed</phrase>
|
||||
<phrase data="public_site_diag_failed_state">failed</phrase>
|
||||
<phrase data="public_site_diag_skipped">skipped</phrase>
|
||||
<phrase data="public_site_diag_no_value">no value</phrase>
|
||||
<phrase data="public_site_diag_empty_template">empty</phrase>
|
||||
<phrase data="public_site_diag_filled_template">filled</phrase>
|
||||
<phrase data="public_site_diag_component">Component</phrase>
|
||||
<phrase data="public_site_diag_tag">Tag</phrase>
|
||||
</language>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="public_site_catalog_filters">Фильтры каталога</phrase>
|
||||
<phrase data="public_site_choose_material">Выберите материал</phrase>
|
||||
<phrase data="public_site_choose_snapshot">Выберите снимок выше.</phrase>
|
||||
<phrase data="public_site_compact_content_list">Компактный список</phrase>
|
||||
<phrase data="public_site_compact_list">Компактный список</phrase>
|
||||
<phrase data="public_site_content_list">Список материалов</phrase>
|
||||
<phrase data="public_site_diag_failed_state">не выполнена</phrase>
|
||||
<phrase data="public_site_diag_passed">выполнена</phrase>
|
||||
<phrase data="public_site_diag_results">Результатов проверки</phrase>
|
||||
<phrase data="public_site_document_page">Страница материала</phrase>
|
||||
<phrase data="public_site_favorite">Избранное</phrase>
|
||||
<phrase data="public_site_items_lower">материалов</phrase>
|
||||
<phrase data="public_site_new_presentation">Новое представление</phrase>
|
||||
<phrase data="public_site_no_snapshots">Снимков пока нет.</phrase>
|
||||
<phrase data="public_site_preview_data_hint">После запуска здесь появятся данные, доступные шаблону.</phrase>
|
||||
<phrase data="public_site_related_items">Похожие материалы</phrase>
|
||||
<phrase data="public_site_search_results">Результаты поиска</phrase>
|
||||
<phrase data="public_site_version_lower">версия</phrase>
|
||||
</language>
|
||||
@@ -0,0 +1,215 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="nav_public_site">Публичный сайт</phrase>
|
||||
<phrase data="public_site_permission">Публичный сайт: просмотр структуры</phrase>
|
||||
<phrase data="public_site_permission_description">Просмотр связей рубрик, документов, запросов, каталогов и шаблонов.</phrase>
|
||||
<phrase data="public_site_intro">Карта связей контента, подборок, каталогов и оформления.</phrase>
|
||||
<phrase data="public_site_content_types">Типов контента</phrase>
|
||||
<phrase data="public_site_materials">Материалов</phrase>
|
||||
<phrase data="public_site_collections">Подборок</phrase>
|
||||
<phrase data="public_site_catalogs">Каталогов</phrase>
|
||||
<phrase data="public_site_components">Блоков и меню</phrase>
|
||||
<phrase data="public_site_product_presentations">Товарных представлений</phrase>
|
||||
<phrase data="public_site_stage_one">Первый этап</phrase>
|
||||
<phrase data="public_site_structure_map">Карта структуры</phrase>
|
||||
<phrase data="public_site_safe_map">Ничего не переключает в паблике: здесь видны существующие сущности и быстрые переходы в их штатные редакторы.</phrase>
|
||||
<phrase data="public_site_content_types_title">Типы контента</phrase>
|
||||
<phrase data="public_site_content_type">Тип контента</phrase>
|
||||
<phrase data="public_site_current_list">в текущем списке</phrase>
|
||||
<phrase data="public_site_open_rubrics">Открыть рубрики</phrase>
|
||||
<phrase data="public_site_search_placeholder">Название, alias или ID</phrase>
|
||||
<phrase data="public_site_show">Показать</phrase>
|
||||
<phrase data="public_site_reset">Сбросить</phrase>
|
||||
<phrase data="public_site_site_template">Шаблон сайта</phrase>
|
||||
<phrase data="public_site_composition">Состав</phrase>
|
||||
<phrase data="public_site_state">Состояние</phrase>
|
||||
<phrase data="public_site_actions">Действия</phrase>
|
||||
<phrase data="public_site_alias_missing">alias не задан</phrase>
|
||||
<phrase data="public_site_not_assigned">не назначен</phrase>
|
||||
<phrase data="public_site_items_lower">материалов</phrase>
|
||||
<phrase data="public_site_fields_lower">полей</phrase>
|
||||
<phrase data="public_site_collections_lower">подборок</phrase>
|
||||
<phrase data="public_site_catalogs_lower">каталогов</phrase>
|
||||
<phrase data="public_site_catalogs_link">Каталоги</phrase>
|
||||
<phrase data="public_site_creation_disabled">создание выключено</phrase>
|
||||
<phrase data="public_site_configured">настроено</phrase>
|
||||
<phrase data="public_site_content_link">Материалы</phrase>
|
||||
<phrase data="public_site_type_settings">Настройки типа</phrase>
|
||||
<phrase data="public_site_not_found">Типы контента не найдены.</phrase>
|
||||
<phrase data="public_site_diagnostics">Диагностика</phrase>
|
||||
<phrase data="public_site_hints_only">Только подсказки, без автоматических изменений</phrase>
|
||||
<phrase data="public_site_no_issues">Явных проблем в текущей карте нет.</phrase>
|
||||
<phrase data="public_site_preview_renderers">Предпросмотр подборок</phrase>
|
||||
<phrase data="public_site_preview_description">Способы проверки результата без изменения публичного сайта</phrase>
|
||||
<phrase data="public_site_unavailable">недоступен</phrase>
|
||||
<phrase data="public_site_presentation_data">Данные представлений</phrase>
|
||||
<phrase data="public_site_palette">Общая палитра ядра и включённых модулей</phrase>
|
||||
<phrase data="public_site_classic_editors">Классические редакторы</phrase>
|
||||
<phrase data="public_site_classic_remain">Привычные разделы остаются основными</phrase>
|
||||
<phrase data="public_site_requests">Запросы и условия</phrase>
|
||||
<phrase data="public_site_blocks">Блоки</phrase>
|
||||
<phrase data="public_site_reusable_fragments">Повторяемые фрагменты</phrase>
|
||||
<phrase data="public_site_navigation">Навигация</phrase>
|
||||
<phrase data="public_site_menu_tree">Меню и дерево пунктов</phrase>
|
||||
<phrase data="public_site_sections_filters">Разделы и фильтры</phrase>
|
||||
<phrase data="public_site_missing_request_item">У подборки не заполнен шаблон элемента</phrase>
|
||||
<phrase data="public_site_missing_site_template">Не назначен существующий шаблон сайта</phrase>
|
||||
<phrase data="public_site_empty_rubric_template">Пустой основной шаблон рубрики</phrase>
|
||||
<phrase data="public_site_no_fields">У типа контента нет полей</phrase>
|
||||
<phrase data="public_site_material_group">Материал</phrase>
|
||||
<phrase data="public_site_context_group">Контекст</phrase>
|
||||
<phrase data="public_site_product_group">Товар</phrase>
|
||||
<phrase data="public_site_actions_group">Действия</phrase>
|
||||
<phrase data="public_site_document_id">ID материала</phrase>
|
||||
<phrase data="public_site_title">Название</phrase>
|
||||
<phrase data="public_site_url">Адрес</phrase>
|
||||
<phrase data="public_site_excerpt">Анонс</phrase>
|
||||
<phrase data="public_site_content_fields">Поля типа контента</phrase>
|
||||
<phrase data="public_site_context_code">Место использования</phrase>
|
||||
<phrase data="public_site_context_position">Позиция в списке</phrase>
|
||||
<phrase data="public_site_article">Артикул</phrase>
|
||||
<phrase data="public_site_price">Цена</phrase>
|
||||
<phrase data="public_site_old_price">Старая цена</phrase>
|
||||
<phrase data="public_site_stock">Остаток</phrase>
|
||||
<phrase data="public_site_images">Изображения</phrase>
|
||||
<phrase data="public_site_badges">Метки</phrase>
|
||||
<phrase data="public_site_variants">Варианты</phrase>
|
||||
<phrase data="public_site_shipping_packages">Грузовые места</phrase>
|
||||
<phrase data="public_site_add_to_cart">Добавить в корзину</phrase>
|
||||
<phrase data="public_site_favorite">Избранное</phrase>
|
||||
<phrase data="public_site_compare">Сравнение</phrase>
|
||||
<phrase data="public_site_legacy_description">Текущие шаблоны main/item и совместимый публичный вывод.</phrase>
|
||||
<phrase data="public_site_data_cards">Карточки данных</phrase>
|
||||
<phrase data="public_site_data_cards_description">Читаемый предпросмотр свойств из контракта результата.</phrase>
|
||||
<phrase data="public_site_compact_list">Компактный список</phrase>
|
||||
<phrase data="public_site_compact_list_description">Название и основные значения в одной строке.</phrase>
|
||||
<phrase data="public_site_table">Таблица</phrase>
|
||||
<phrase data="public_site_table_description">Материалы по строкам, выбранные данные по колонкам.</phrase>
|
||||
<phrase data="public_site_json_description">Структурированный результат для API и интеграций.</phrase>
|
||||
<phrase data="public_site_help_title">Публичный сайт</phrase>
|
||||
<phrase data="public_site_help_description">Показывает связи типов контента, материалов, подборок, каталогов и представлений в одном месте.</phrase>
|
||||
<phrase data="public_site_help_map">Карта ничего не переключает в публичной части и ведёт в существующие редакторы.</phrase>
|
||||
<phrase data="public_site_help_attributes">Поля рубрик остаются универсальным хранилищем контента; атрибуты дополняют их для фильтров и вариантов.</phrase>
|
||||
<phrase data="public_site_help_diagnostics">Диагностика сообщает только о явных пробелах в обязательных шаблонах.</phrase>
|
||||
<phrase data="public_site_help_note">Классические разделы остаются полноценным способом настройки сайта.</phrase>
|
||||
<phrase data="public_site_sections_label">Разделы публичного сайта</phrase>
|
||||
<phrase data="public_site_presentations">Представления</phrase>
|
||||
<phrase data="public_site_presentations_intro">Повторно используемое оформление материалов, подборок и каталогов.</phrase>
|
||||
<phrase data="public_site_new_presentation">Новое представление</phrase>
|
||||
<phrase data="public_site_storage_missing">Хранилище ещё не создано</phrase>
|
||||
<phrase data="public_site_apply_migration">Примените миграцию раздела «Публичный сайт».</phrase>
|
||||
<phrase data="public_site_content_presentation">Оформление контента</phrase>
|
||||
<phrase data="public_site_shared_presentations">Общие представления</phrase>
|
||||
<phrase data="public_site_draft_safe">Черновик и предпросмотр не меняют действующий публичный сайт.</phrase>
|
||||
<phrase data="public_site_presentation_kind">Вид представления</phrase>
|
||||
<phrase data="public_site_all_kinds">Все виды</phrase>
|
||||
<phrase data="public_site_unpublished">Не опубликованные</phrase>
|
||||
<phrase data="public_site_version_lower">версия</phrase>
|
||||
<phrase data="public_site_no_presentations">Представлений пока нет.</phrase>
|
||||
<phrase data="public_site_compatibility">Совместимость</phrase>
|
||||
<phrase data="public_site_current_editors">Действующие редакторы</phrase>
|
||||
<phrase data="public_site_current_editors_hint">Они продолжают управлять текущим выводом и не копируются автоматически.</phrase>
|
||||
<phrase data="public_site_ctrl_save">Ctrl/Cmd+S сохраняет черновик и оставляет редактор открытым.</phrase>
|
||||
<phrase data="public_site_stable_assignment_name">Стабильное имя для назначений.</phrase>
|
||||
<phrase data="public_site_purpose_example">Например: карточка материала в списке новостей</phrase>
|
||||
<phrase data="public_site_presentation_editor">Редактор представления</phrase>
|
||||
<phrase data="public_site_wrapper">Обёртка</phrase>
|
||||
<phrase data="public_site_empty_result">Пустой результат</phrase>
|
||||
<phrase data="public_site_assignments">Назначения</phrase>
|
||||
<phrase data="public_site_item_template">Шаблон элемента</phrase>
|
||||
<phrase data="public_site_item_available">Один материал доступен как item.</phrase>
|
||||
<phrase data="public_site_list_wrapper">Обёртка списка</phrase>
|
||||
<phrase data="public_site_wrapper_hint">Готовые элементы доступны в content, исходные данные — в items.</phrase>
|
||||
<phrase data="public_site_empty_hint">Показывается, когда подборка не вернула материалов.</phrase>
|
||||
<phrase data="public_site_presentation_css">CSS представления</phrase>
|
||||
<phrase data="public_site_css_hint">Стили относятся только к этому представлению.</phrase>
|
||||
<phrase data="public_site_available_data">Доступные данные</phrase>
|
||||
<phrase data="public_site_insert_data_hint">Нажмите значение, чтобы вставить его в шаблон элемента.</phrase>
|
||||
<phrase data="public_site_real_preview">Предпросмотр на реальном материале</phrase>
|
||||
<phrase data="public_site_preview_safe">Используется только черновик, публичный сайт не меняется.</phrase>
|
||||
<phrase data="public_site_choose_material">Выберите материал</phrase>
|
||||
<phrase data="public_site_preview_data_hint">После запуска здесь появятся данные, доступные шаблону.</phrase>
|
||||
<phrase data="public_site_where_to_use">Где использовать</phrase>
|
||||
<phrase data="public_site_legacy_mode_hint">Режим «Текущий вывод» только фиксирует связь и ничего не переключает.</phrase>
|
||||
<phrase data="public_site_content_list">Список материалов</phrase>
|
||||
<phrase data="public_site_compact_content_list">Компактный список</phrase>
|
||||
<phrase data="public_site_search_results">Результаты поиска</phrase>
|
||||
<phrase data="public_site_related_items">Похожие материалы</phrase>
|
||||
<phrase data="public_site_catalog_filters">Фильтры каталога</phrase>
|
||||
<phrase data="public_site_document_page">Страница материала</phrase>
|
||||
<phrase data="public_site_apply_to">Применить к</phrase>
|
||||
<phrase data="public_site_target_code">ID или код цели</phrase>
|
||||
<phrase data="public_site_assign">Назначить</phrase>
|
||||
<phrase data="public_site_change_history">История изменений</phrase>
|
||||
<phrase data="public_site_no_snapshots">Снимков пока нет.</phrase>
|
||||
<phrase data="public_site_choose_snapshot">Выберите снимок выше.</phrase>
|
||||
<phrase data="public_site_place">Место</phrase>
|
||||
<phrase data="public_site_everywhere">Везде</phrase>
|
||||
<phrase data="public_site_to_content_type">Типу контента</phrase>
|
||||
<phrase data="public_site_to_collection">Подборке</phrase>
|
||||
<phrase data="public_site_to_catalog">Каталогу</phrase>
|
||||
<phrase data="public_site_to_module">Модулю</phrase>
|
||||
<phrase data="public_site_diag_page_title">Диагностика публичной страницы</phrase>
|
||||
<phrase data="public_site_diag_page_intro">Показывает, из чего собрана страница и почему документ попадает в подборку. Ничего не сохраняет и не переключает.</phrase>
|
||||
<phrase data="public_site_diag_page_context">Контекст страницы</phrase>
|
||||
<phrase data="public_site_diag_page_composition">Из чего собрана страница</phrase>
|
||||
<phrase data="public_site_diag_page_hint">Введите публичный URL или ID документа. Проверка читает сохранённые данные без запуска страницы.</phrase>
|
||||
<phrase data="public_site_diag_inspect">Разобрать страницу</phrase>
|
||||
<phrase data="public_site_diag_failed">Страница не разобрана</phrase>
|
||||
<phrase data="public_site_diag_results">Результатов проверки</phrase>
|
||||
<phrase data="public_site_diag_open_document">Открыть документ</phrase>
|
||||
<phrase data="public_site_diag_open_setting">Открыть место настройки</phrase>
|
||||
<phrase data="public_site_diag_render_chain">Цепочка вывода</phrase>
|
||||
<phrase data="public_site_diag_templates">Шаблоны</phrase>
|
||||
<phrase data="public_site_diag_characters">символов</phrase>
|
||||
<phrase data="public_site_diag_document_cache">Кеш документа</phrase>
|
||||
<phrase data="public_site_diag_full_page">Полная страница</phrase>
|
||||
<phrase data="public_site_diag_document_template">Шаблон документа</phrase>
|
||||
<phrase data="public_site_diag_not_created">не создан</phrase>
|
||||
<phrase data="public_site_diag_dependencies">Зависимости</phrase>
|
||||
<phrase data="public_site_diag_connected_components">Подключённые компоненты</phrase>
|
||||
<phrase data="public_site_diag_components_hint">Блоки, подборки, меню и модули, найденные в активной цепочке шаблонов.</phrase>
|
||||
<phrase data="public_site_diag_where_connected">Где подключено</phrase>
|
||||
<phrase data="public_site_diag_open_component">Открыть компонент</phrase>
|
||||
<phrase data="public_site_diag_no_components">Компоненты в шаблонах не найдены</phrase>
|
||||
<phrase data="public_site_diag_document_schema">Схема документа</phrase>
|
||||
<phrase data="public_site_diag_fields_values">Поля и фактические значения</phrase>
|
||||
<phrase data="public_site_diag_fields_hint">Показываются все поля рубрики, включая пустые и обязательные.</phrase>
|
||||
<phrase data="public_site_diag_field">Поле</phrase>
|
||||
<phrase data="public_site_diag_type">Тип</phrase>
|
||||
<phrase data="public_site_diag_value">Значение</phrase>
|
||||
<phrase data="public_site_diag_required_empty">обязательное не заполнено</phrase>
|
||||
<phrase data="public_site_diag_empty">пусто</phrase>
|
||||
<phrase data="public_site_diag_filled">заполнено</phrase>
|
||||
<phrase data="public_site_diag_no_fields">У рубрики нет полей</phrase>
|
||||
<phrase data="public_site_diag_rubric_collections">Подборки рубрики</phrase>
|
||||
<phrase data="public_site_diag_no_alias">без alias</phrase>
|
||||
<phrase data="public_site_diag_no_requests">Запросов для этой рубрики нет</phrase>
|
||||
<phrase data="public_site_diag_direct_module_hint">Документ может использоваться напрямую или через модуль.</phrase>
|
||||
<phrase data="public_site_diag_result_design">Оформление результатов</phrase>
|
||||
<phrase data="public_site_diag_no_presentations">Назначенных представлений нет</phrase>
|
||||
<phrase data="public_site_diag_standard_templates">Страница использует обычные шаблоны AVE.cms.</phrase>
|
||||
<phrase data="public_site_diag_structured_page">Структурированный пакет страницы</phrase>
|
||||
<phrase data="public_site_diag_why_in_request">Почему документ попал в запрос</phrase>
|
||||
<phrase data="public_site_diag_parameters_hint">Параметры можно передать как</phrase>
|
||||
<phrase data="public_site_diag_or_json">или JSON-объект.</phrase>
|
||||
<phrase data="public_site_diag_request_id">ID запроса</phrase>
|
||||
<phrase data="public_site_diag_document_id">ID документа</phrase>
|
||||
<phrase data="public_site_diag_page_parameters">Параметры страницы</phrase>
|
||||
<phrase data="public_site_diag_explain">Объяснить</phrase>
|
||||
<phrase data="public_site_diag_request_failed">Запрос не проверен</phrase>
|
||||
<phrase data="public_site_diag_check_result">Итог проверки</phrase>
|
||||
<phrase data="public_site_diag_document_included">Документ входит в результат</phrase>
|
||||
<phrase data="public_site_diag_document_excluded">Документ не входит в результат</phrase>
|
||||
<phrase data="public_site_diag_found">найден</phrase>
|
||||
<phrase data="public_site_diag_excluded">исключён</phrase>
|
||||
<phrase data="public_site_diag_structured_request">Структурированное объяснение запроса</phrase>
|
||||
<phrase data="public_site_diag_passed">выполнена</phrase>
|
||||
<phrase data="public_site_diag_failed_state">не выполнена</phrase>
|
||||
<phrase data="public_site_diag_skipped">не участвовала</phrase>
|
||||
<phrase data="public_site_diag_no_value">нет значения</phrase>
|
||||
<phrase data="public_site_diag_empty_template">пустой</phrase>
|
||||
<phrase data="public_site_diag_filled_template">заполнен</phrase>
|
||||
<phrase data="public_site_diag_component">Компонент</phrase>
|
||||
<phrase data="public_site_diag_tag">Тег</phrase>
|
||||
</language>
|
||||
@@ -0,0 +1,56 @@
|
||||
CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(190) NOT NULL,
|
||||
`code` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`kind` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'card',
|
||||
`description` TEXT NULL,
|
||||
`draft_item_markup` LONGTEXT NOT NULL,
|
||||
`draft_wrapper_markup` LONGTEXT NOT NULL,
|
||||
`draft_empty_markup` LONGTEXT NOT NULL,
|
||||
`draft_css` LONGTEXT NOT NULL,
|
||||
`draft_settings_json` TEXT NULL,
|
||||
`published_item_markup` LONGTEXT NOT NULL,
|
||||
`published_wrapper_markup` LONGTEXT NOT NULL,
|
||||
`published_empty_markup` LONGTEXT NOT NULL,
|
||||
`published_css` LONGTEXT NOT NULL,
|
||||
`published_settings_json` TEXT NULL,
|
||||
`is_published` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`version` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`author_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`published_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_content_presentation_code` (`code`),
|
||||
KEY `idx_content_presentation_kind` (`kind`, `is_published`),
|
||||
KEY `idx_content_presentation_updated` (`updated_at`)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentation_revisions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`presentation_id` INT UNSIGNED NOT NULL,
|
||||
`action` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`snapshot_hash` CHAR(40) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`snapshot_json` LONGTEXT NOT NULL,
|
||||
`comment` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`author_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`author_name` VARCHAR(190) NOT NULL DEFAULT '',
|
||||
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_presentation_revision_owner` (`presentation_id`, `created_at`, `id`)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentation_assignments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`presentation_id` INT UNSIGNED NOT NULL,
|
||||
`context_code` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`target_type` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`target_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '0',
|
||||
`mode` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'legacy',
|
||||
`settings_json` TEXT NULL,
|
||||
`author_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_presentation_assignment_target` (`context_code`, `target_type`, `target_key`),
|
||||
KEY `idx_presentation_assignment_owner` (`presentation_id`, `mode`)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user