mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-05 18:35:44 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918c989f91 | ||
|
|
997ffe08eb | ||
|
|
af9abc0047 |
@@ -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/
|
||||
|
||||
+388
-246
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
@@ -5,11 +5,128 @@
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
|
||||
border-top: 1px solid var(--border-strong);
|
||||
.catalog-tree {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px;
|
||||
overflow-x: hidden;
|
||||
background: var(--background-subtle);
|
||||
}
|
||||
.catalog-tree-row {
|
||||
grid-template-columns: 30px 22px minmax(180px, 1fr) 112px 70px 84px;
|
||||
.catalog-tree.is-drag-active {
|
||||
user-select: none;
|
||||
}
|
||||
.catalog-tree.is-order-saving::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
content: '';
|
||||
cursor: wait;
|
||||
background: rgba(248, 250, 252, 0.36);
|
||||
}
|
||||
.catalog-tree > .catalog-tree-item {
|
||||
width: calc(100% - var(--catalog-indent, 0px));
|
||||
min-width: 0;
|
||||
margin-left: var(--catalog-indent, 0px);
|
||||
}
|
||||
.catalog-tree-row,
|
||||
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 30px minmax(180px, 1fr) auto 104px 258px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
outline: 0;
|
||||
background: var(--background-card);
|
||||
box-shadow: inset 0 0 0 1px var(--border-default);
|
||||
cursor: pointer;
|
||||
transition-property: background-color, box-shadow, opacity;
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.catalog-tree-row:hover {
|
||||
background: var(--blue-50);
|
||||
box-shadow: inset 0 0 0 1px var(--blue-300);
|
||||
}
|
||||
.catalog-tree-row.is-selected {
|
||||
background: var(--blue-50);
|
||||
box-shadow: inset 3px 0 var(--blue-500), inset 0 0 0 1px var(--blue-300);
|
||||
}
|
||||
.catalog-tree-position {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-muted);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.catalog-tree-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.catalog-tree-meta .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.catalog-tree-count {
|
||||
min-width: 42px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.catalog-tree-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.catalog-tree-actions .btn-icon {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.catalog-tree-actions .btn-icon:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.catalog-tree-actions .btn-icon:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
.catalog-action-level {
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.catalog-action-sibling {
|
||||
color: var(--green-600);
|
||||
}
|
||||
.catalog-action-child {
|
||||
color: var(--cyan-700);
|
||||
}
|
||||
.catalog-action-edit {
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.catalog-action-delete {
|
||||
color: var(--red-600);
|
||||
}
|
||||
.catalog-drag {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.catalog-drag:hover,
|
||||
.catalog-drag:focus-visible {
|
||||
background: var(--background-muted);
|
||||
color: var(--blue-600);
|
||||
}
|
||||
.catalog-tree-tools {
|
||||
display: flex;
|
||||
@@ -39,21 +156,53 @@
|
||||
}
|
||||
.catalog-tree-item.is-inactive > .catalog-tree-row {
|
||||
background: var(--amber-50);
|
||||
box-shadow: inset 3px 0 var(--amber-500);
|
||||
box-shadow: inset 3px 0 var(--amber-500), inset 0 0 0 1px var(--amber-200);
|
||||
}
|
||||
.catalog-tree-item.is-drop-target > .catalog-tree-row {
|
||||
background: var(--blue-50);
|
||||
.catalog-tree-row.drag-over-top {
|
||||
box-shadow: inset 0 2px var(--blue-500);
|
||||
}
|
||||
.catalog-tree-item.is-drop-target.is-drop-after > .catalog-tree-row {
|
||||
.catalog-tree-row.drag-over-bottom {
|
||||
box-shadow: inset 0 -2px var(--blue-500);
|
||||
}
|
||||
.catalog-tree-item.is-drop-target.is-drop-inside > .catalog-tree-row {
|
||||
background: var(--blue-50);
|
||||
box-shadow: inset 0 0 0 2px var(--blue-500);
|
||||
.catalog-tree > .catalog-tree-item.is-dragging,
|
||||
.catalog-tree > .catalog-tree-item.is-dragging-child {
|
||||
display: none;
|
||||
}
|
||||
.catalog-tree-item.is-dragging > .catalog-tree-row {
|
||||
opacity: 0.58;
|
||||
.catalog-tree-placeholder {
|
||||
min-height: 52px;
|
||||
border: 1px dashed var(--blue-500);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--blue-50);
|
||||
box-shadow: inset 0 0 0 3px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
.catalog-tree-ghost {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3000;
|
||||
max-width: calc(100vw - 24px);
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
opacity: 0.96;
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.2);
|
||||
will-change: transform;
|
||||
}
|
||||
.catalog-tree-ghost .catalog-tree-meta,
|
||||
.catalog-tree-ghost .catalog-tree-status,
|
||||
.catalog-tree-ghost .catalog-tree-actions {
|
||||
display: none;
|
||||
}
|
||||
.catalog-tree-ghost {
|
||||
grid-template-columns: 30px 30px minmax(160px, 1fr);
|
||||
}
|
||||
.catalog-tree-dragging,
|
||||
.catalog-tree-dragging * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
.catalog-tree.is-filtering .catalog-action-level,
|
||||
.catalog-tree.is-filtering .catalog-drag {
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
}
|
||||
.catalog-item-form {
|
||||
display: flex;
|
||||
@@ -217,7 +366,6 @@
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.catalog-field-groups {
|
||||
display: grid;
|
||||
@@ -299,7 +447,6 @@
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.catalog-field-group-meta .badge {
|
||||
min-width: 88px;
|
||||
@@ -459,7 +606,6 @@
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.catalog-recompile-errors {
|
||||
margin-top: 14px;
|
||||
@@ -744,8 +890,12 @@
|
||||
.catalog-behavior-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.catalog-tree-row {
|
||||
grid-template-columns: 30px 18px minmax(140px, 1fr) 60px 70px;
|
||||
.catalog-tree-row,
|
||||
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
|
||||
grid-template-columns: 30px 30px minmax(140px, 1fr) 94px 258px;
|
||||
}
|
||||
.catalog-tree-meta {
|
||||
display: none;
|
||||
}
|
||||
.catalog-tree-status > span {
|
||||
display: none;
|
||||
@@ -776,8 +926,33 @@
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.catalog-tree-row {
|
||||
grid-template-columns: 28px minmax(120px, 1fr) 54px 76px;
|
||||
@media (max-width: 760px) {
|
||||
.catalog-tree {
|
||||
padding: 9px;
|
||||
}
|
||||
.catalog-tree > .catalog-tree-item {
|
||||
width: calc(100% - min(var(--catalog-indent, 0px), 80px));
|
||||
margin-left: min(var(--catalog-indent, 0px), 80px);
|
||||
}
|
||||
.catalog-tree-row,
|
||||
.catalog-tree > .catalog-tree-item > .catalog-tree-row {
|
||||
grid-template-columns: 28px 26px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
.catalog-tree-name {
|
||||
grid-column: 3;
|
||||
}
|
||||
.catalog-tree-status {
|
||||
grid-column: 4;
|
||||
}
|
||||
.catalog-tree-actions {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.catalog-tree-actions .btn-icon {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
'use strict';
|
||||
var Adminx = window.Adminx || (window.Adminx = {});
|
||||
Adminx.Catalog = {
|
||||
itemForm: null, settingsForm: null, dragRow: null, dragParent: null, dragSnapshot: '', dragTarget: null, filterOrder: [], itemSaveTimer: null, settingsSaveTimer: null, conditionContext: null,
|
||||
itemForm: null, settingsForm: null, dragItem: null, dragGroup: [], dragPlaceholder: null, dragGhost: null, dragPointerId: null, dragStartX: 0, dragStartY: 0, dragStarted: false, dragOrderSnapshot: '', orderSaving: false, filterOrder: [], itemSaveTimer: null, settingsSaveTimer: null, conditionContext: null,
|
||||
init: function () {
|
||||
this.itemForm = document.getElementById('catalogItemForm');
|
||||
this.settingsForm = document.getElementById('catalogSettingsForm');
|
||||
@@ -12,11 +12,19 @@
|
||||
if (pageTab) { self.tab('[data-catalog-page-tab]', '[data-catalog-page-panel]', pageTab.getAttribute('data-catalog-page-tab')); }
|
||||
var drawerTab = e.target.closest('[data-catalog-drawer-tab]');
|
||||
if (drawerTab) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', drawerTab.getAttribute('data-catalog-drawer-tab')); }
|
||||
if (e.target.closest('[data-catalog-item-new]')) { self.newItem(); }
|
||||
if (e.target.closest('[data-catalog-item-new]')) { self.newItem(0); }
|
||||
var indent = e.target.closest('[data-catalog-item-indent]');
|
||||
if (indent) { self.changeItemLevel(indent.closest('[data-catalog-item]'), parseInt(indent.getAttribute('data-catalog-item-indent'), 10) || 0); return; }
|
||||
var sibling = e.target.closest('[data-catalog-item-sibling]');
|
||||
if (sibling) { var siblingNode = sibling.closest('[data-catalog-item]'); self.newItem(Number(siblingNode.getAttribute('data-parent-id')) || 0); return; }
|
||||
var child = e.target.closest('[data-catalog-item-child]');
|
||||
if (child) { self.newItem(Number(child.getAttribute('data-catalog-item-child')) || 0); return; }
|
||||
var edit = e.target.closest('[data-catalog-item-edit]');
|
||||
if (edit) { self.editItem(edit.getAttribute('data-catalog-item-edit')); }
|
||||
if (edit) { self.editItem(edit.getAttribute('data-catalog-item-edit')); return; }
|
||||
var del = e.target.closest('[data-catalog-item-delete]');
|
||||
if (del) { self.deleteItem(del.getAttribute('data-catalog-item-delete')); }
|
||||
if (del) { self.deleteItem(del.getAttribute('data-catalog-item-delete')); return; }
|
||||
var treeRow = e.target.closest('[data-catalog-builder-item]');
|
||||
if (treeRow && !e.target.closest('button, input, label, a, select')) { self.editItem(treeRow.getAttribute('data-catalog-builder-item')); return; }
|
||||
if (e.target.closest('[data-catalog-document-pick]')) { self.openDocumentPicker(); }
|
||||
if (e.target.closest('[data-catalog-document-clear]')) { self.setDocument(null); self.scheduleItemSave(); }
|
||||
var conditionView = e.target.closest('[data-catalog-condition-view]');
|
||||
@@ -45,10 +53,11 @@
|
||||
if (this.settingsForm) { this.settingsForm.addEventListener('submit', function (e) { e.preventDefault(); self.saveSettings(false); }); this.updateSettingsGroups(); this.updateCommerceSettings(); }
|
||||
var createForm = document.querySelector('[data-catalog-create]');
|
||||
if (createForm) { createForm.addEventListener('submit', function (e) { e.preventDefault(); self.createCatalog(createForm); }); }
|
||||
document.addEventListener('dragstart', function (e) { self.dragStart(e); });
|
||||
document.addEventListener('dragover', function (e) { self.dragOver(e); });
|
||||
document.addEventListener('drop', function (e) { if (self.dragRow) { e.preventDefault(); } });
|
||||
document.addEventListener('dragend', function () { self.dragEnd(); });
|
||||
document.addEventListener('pointerdown', function (e) { self.treeDragStart(e); });
|
||||
document.addEventListener('pointermove', function (e) { self.treeDragOver(e); });
|
||||
document.addEventListener('pointerup', function (e) { self.treeDrop(e); });
|
||||
document.addEventListener('pointercancel', function () { self.treeDragEnd(); });
|
||||
this.normalizeTreeHierarchy();
|
||||
this.openRequestedItem();
|
||||
},
|
||||
base: function () { return (this.itemForm || this.settingsForm).getAttribute('data-base'); },
|
||||
@@ -66,8 +75,11 @@
|
||||
document.querySelectorAll(tabs).forEach(function (el) { var active = el.getAttribute(tabs.indexOf('drawer') >= 0 ? 'data-catalog-drawer-tab' : 'data-catalog-page-tab') === value; el.classList.toggle('is-active', active); el.setAttribute('aria-selected', active ? 'true' : 'false'); });
|
||||
document.querySelectorAll(panels).forEach(function (el) { el.hidden = el.getAttribute(panels.indexOf('drawer') >= 0 ? 'data-catalog-drawer-panel' : 'data-catalog-page-panel') !== value; });
|
||||
},
|
||||
newItem: function () {
|
||||
newItem: function (parentId) {
|
||||
this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = ''; this.itemForm.querySelector('[name="status"]').checked = true;
|
||||
this.itemForm.querySelectorAll('[name="parent_id"] option').forEach(function (option) { option.disabled = false; });
|
||||
var parent = this.itemForm.querySelector('[name="parent_id"]');
|
||||
if (parent) { parent.value = String(Number(parentId) || 0); }
|
||||
var defaultFields = this.settingsValues('fields_default[]'), defaultFilters = this.settingsValues('filters_default[]');
|
||||
this.checkValues('fields_use[]', defaultFields); this.checkValues('filters_use[]', defaultFilters);
|
||||
defaultFilters.forEach(function (id) { var source = this.settingsForm.querySelector('[name="filter_style[' + id + ']"]'), target = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (source && target) { target.value = source.value; } }, this);
|
||||
@@ -80,7 +92,7 @@
|
||||
updateSettingsGroups: function () { if (!this.settingsForm) { return; } this.settingsForm.querySelectorAll('[data-catalog-settings-group]').forEach(function (group) { var name = group.getAttribute('data-catalog-settings-group'), selected = group.querySelectorAll('[name="' + name + '"]:checked').length, badge = group.querySelector('[data-catalog-settings-selected]'); group.querySelectorAll('.catalog-choice-row, .catalog-filter-row').forEach(function (row) { var input = row.querySelector('[name="' + name + '"]'); row.classList.toggle('is-selected', !!input && input.checked); }); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); },
|
||||
updateCommerceSettings: function () { if (!this.settingsForm) { return; } var purpose = this.settingsForm.querySelector('[name="purpose"]'), visible = purpose && purpose.value === 'commerce'; this.settingsForm.querySelectorAll('[data-catalog-commerce-fields]').forEach(function (section) { section.hidden = !visible; }); },
|
||||
editItem: function (id, requestedTab) {
|
||||
var self = this; Adminx.Loader.show();
|
||||
var self = this; this.markSelectedItem(id); Adminx.Loader.show();
|
||||
fetch(this.base() + '/catalog/items/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) {
|
||||
self.fill(payload.data || {}); Adminx.Drawer.open('catalogItemDrawer');
|
||||
if (['main', 'fields', 'filters'].indexOf(requestedTab) >= 0) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', requestedTab); }
|
||||
@@ -94,13 +106,19 @@
|
||||
},
|
||||
fill: function (item) {
|
||||
this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = item.id || ''; this.itemForm.querySelector('[name="name"]').value = item.name || '';
|
||||
this.itemForm.querySelectorAll('[name="parent_id"] option').forEach(function (option) { option.disabled = false; });
|
||||
this.itemForm.querySelector('[name="parent_id"]').value = item.parent_id || 0; this.itemForm.querySelector('[name="status"]').checked = Number(item.status) === 1;
|
||||
this.setDocument(item.document_id ? { id: item.document_id, title: item.document_title || '', alias: item.document_alias || '' } : null);
|
||||
this.filterOrder = (item.filters_use || []).map(function (id) { return String(id); }); this.syncFilterOrderInput(); this.checkValues('fields_use[]', item.fields_use || []); this.checkValues('filters_use[]', item.filters_use || []);
|
||||
Object.keys(item.filter_styles || {}).forEach(function (id) { var select = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (select) { select.value = item.filter_styles[id]; } }, this);
|
||||
var own = this.itemForm.querySelector('[name="parent_id"] option[value="' + item.id + '"]'); if (own) { own.disabled = true; }
|
||||
var treeItem = document.querySelector('[data-catalog-item][data-id="' + item.id + '"]');
|
||||
this.treeBranch(treeItem).slice(1).forEach(function (child) {
|
||||
var option = this.itemForm.querySelector('[name="parent_id"] option[value="' + child.getAttribute('data-id') + '"]');
|
||||
if (option) { option.disabled = true; }
|
||||
}, this);
|
||||
this.renderConditionContext(item.condition_context || null);
|
||||
document.querySelector('[data-catalog-drawer-title]').textContent = item.name || 'Раздел каталога'; this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); this.setState('item', '');
|
||||
document.querySelector('[data-catalog-drawer-title]').textContent = item.name || 'Раздел каталога'; this.markSelectedItem(item.id); this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); this.setState('item', '');
|
||||
},
|
||||
checkValues: function (name, values) { var map = {}; values.forEach(function (id) { map[String(id)] = true; }); this.itemForm.querySelectorAll('[name="' + name + '"]').forEach(function (el) { el.checked = !!map[el.value]; }); },
|
||||
updateCounts: function () { ['fields', 'filters'].forEach(function (type) { var count = this.itemForm.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var el = document.querySelector('[data-catalog-' + type + '-count]'); if (el) { el.textContent = count; } this.itemForm.querySelectorAll('[name="' + type + '_use[]"]').forEach(function (toggle) { var row = toggle.closest('.catalog-choice-row, .catalog-filter-row'); if (row) { row.classList.toggle('is-selected', toggle.checked); } }); this.itemForm.querySelectorAll('[data-catalog-field-group="' + type + '"]').forEach(function (group) { var selected = group.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var badge = group.querySelector('[data-catalog-group-selected]'); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); }, this); var orderButton = this.itemForm.querySelector('[data-catalog-filter-order]'); if (orderButton) { orderButton.disabled = this.filterOrder.length < 2; } this.renderConditionContext(this.conditionContext); },
|
||||
@@ -160,7 +178,7 @@
|
||||
},
|
||||
saveSettings: function (auto) { var self = this; this.ajax(this.url() + '/settings', new FormData(this.settingsForm), function (payload) { if (auto) { self.setState('settings', 'Сохранено', 'ok'); } else { self.setState('settings', 'Сохранено', 'ok'); Adminx.Toast.show(payload.message, 'success'); } }, { quiet: !!auto, fail: function () { self.setState('settings', 'Не сохранено', 'error'); } }); },
|
||||
setState: function (type, message, state) { var el = document.querySelector('[data-catalog-' + type + '-state]'); if (!el) { return; } el.textContent = message || ''; el.classList.toggle('is-ok', state === 'ok'); el.classList.toggle('is-error', state === 'error'); },
|
||||
syncTreeRow: function (id) { var row = document.querySelector('[data-catalog-item][data-id="' + id + '"]'); if (!row) { return; } var toggle = row.querySelector('[data-catalog-item-status]'), active = this.itemForm.querySelector('[name="status"]').checked, filterCount = this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').length; if (toggle) { toggle.checked = active; this.updateTreeStatus(toggle); } row.setAttribute('data-filter-count', String(filterCount)); var count = row.querySelector('.catalog-tree-count'); if (count) { count.textContent = this.itemForm.querySelectorAll('[name="fields_use[]"]:checked').length + ' / ' + filterCount; } },
|
||||
syncTreeRow: function (id) { var row = document.querySelector('[data-catalog-item][data-id="' + id + '"]'); if (!row) { return; } var toggle = row.querySelector('[data-catalog-item-status]'), active = this.itemForm.querySelector('[name="status"]').checked, filterCount = this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').length, name = row.querySelector('.catalog-tree-name b'); if (toggle) { toggle.checked = active; this.updateTreeStatus(toggle); } if (name) { name.textContent = this.itemForm.querySelector('[name="name"]').value || 'Без названия'; } row.setAttribute('data-filter-count', String(filterCount)); var count = row.querySelector('.catalog-tree-count'); if (count) { count.textContent = this.itemForm.querySelectorAll('[name="fields_use[]"]:checked').length + ' / ' + filterCount; } },
|
||||
updateTreeStatus: function (input) { var item = input.closest('[data-catalog-item]'), active = input.checked, label = item.querySelector('[data-catalog-item-status-label]'), control = input.closest('.switch'); item.classList.toggle('is-inactive', !active); if (label) { label.textContent = active ? 'активен' : 'скрыт'; } input.setAttribute('aria-label', active ? 'Скрыть раздел' : 'Включить раздел'); if (control) { control.setAttribute('data-tooltip', active ? 'Скрыть раздел' : 'Включить раздел'); } },
|
||||
setTreeStatus: function (input) { var self = this, id = input.getAttribute('data-catalog-item-status'), previous = !input.checked, data = new FormData(); this.updateTreeStatus(input); input.disabled = true; data.append('_csrf', this.csrf()); data.append('status', input.checked ? '1' : '0'); this.ajax(this.url() + '/items/' + encodeURIComponent(id) + '/status', data, function (payload) { input.checked = Number(payload.data.status) === 1; input.disabled = false; self.updateTreeStatus(input); }, { quiet: true, fail: function () { input.checked = previous; input.disabled = false; self.updateTreeStatus(input); } }); },
|
||||
setDocument: function (item) { if (!this.itemForm) { return; } var input = this.itemForm.querySelector('[name="document_id"]'); var label = this.itemForm.querySelector('[data-catalog-document-label]'); var clear = this.itemForm.querySelector('[data-catalog-document-clear]'); var id = item && item.id ? Number(item.id) : 0; input.value = id || ''; if (label) { label.textContent = id ? ('#' + id + ' · ' + (item.title || item.alias || 'Без названия')) : 'Документ не выбран'; } if (clear) { clear.disabled = !id; } },
|
||||
@@ -180,12 +198,239 @@
|
||||
var self = this, run = function () { var data = new FormData(); data.append('_csrf', self.csrf()); self.ajax(self.url() + '/items/' + id + '/delete', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); window.location.reload(); }); };
|
||||
if (Adminx.Confirm) { Adminx.Confirm.open({ kind: 'error', title: 'Удалить раздел?', message: 'Будут удалены также все вложенные разделы. Документы останутся.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', onConfirm: run }); } else if (confirm('Удалить раздел и все вложенные?')) { run(); }
|
||||
},
|
||||
search: function (value) { value = String(value || '').trim().toLowerCase(); document.querySelectorAll('[data-catalog-item]').forEach(function (row) { row.classList.toggle('is-filtered', value && row.textContent.toLowerCase().indexOf(value) < 0); }); },
|
||||
dragStart: function (e) { var handle = e.target.closest('.catalog-drag[draggable="true"]'), row = handle ? handle.closest('[data-catalog-item]') : null; if (!row) { e.preventDefault(); return; } this.dragRow = row; this.dragParent = row.parentNode; this.dragSnapshot = this.siblingOrder(this.dragParent); row.classList.add('is-dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.getAttribute('data-id')); },
|
||||
dragOver: function (e) { if (!this.dragRow) { return; } var target = e.target.closest('[data-catalog-item]'); if (!target || target === this.dragRow || this.dragRow.contains(target)) { return; } var row = target.querySelector(':scope > .catalog-tree-row'), rect = row.getBoundingClientRect(), ratio = (e.clientY - rect.top) / rect.height, mode = ratio < .28 ? 'before' : (ratio > .72 ? 'after' : 'inside'), destination = null, reference = null, parentId = 0; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dragTarget && this.dragTarget !== target) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } this.dragTarget = target; target.classList.add('is-drop-target'); target.classList.toggle('is-drop-after', mode === 'after'); target.classList.toggle('is-drop-inside', mode === 'inside'); if (mode === 'inside') { destination = target.querySelector(':scope > .catalog-tree-children'); if (!destination) { destination = document.createElement('ol'); destination.className = 'catalog-tree-children'; target.appendChild(destination); } parentId = Number(target.getAttribute('data-id')); } else { destination = target.parentNode; reference = mode === 'before' ? target : target.nextSibling; parentId = Number(target.getAttribute('data-parent-id')); } if (destination !== this.dragRow.parentNode || (reference !== this.dragRow && this.dragRow.nextSibling !== reference)) { destination.insertBefore(this.dragRow, reference); this.dragRow.setAttribute('data-parent-id', String(parentId)); } },
|
||||
dragEnd: function () { if (!this.dragRow) { return; } var changed = this.dragSnapshot !== this.siblingOrder(this.dragParent); this.dragRow.classList.remove('is-dragging'); if (this.dragTarget) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } document.querySelectorAll('.catalog-tree-children').forEach(function (list) { if (!list.querySelector(':scope > [data-catalog-item]')) { list.remove(); } }); this.dragRow = null; this.dragParent = null; this.dragTarget = null; this.dragSnapshot = ''; if (changed) { this.saveOrder(); } },
|
||||
siblingOrder: function (parent) { return Array.prototype.filter.call(parent ? parent.children : [], function (el) { return el.matches('[data-catalog-item]'); }).map(function (el) { return el.getAttribute('data-id'); }).join(','); },
|
||||
saveOrder: function () { var rows = []; document.querySelectorAll('[data-catalog-item]').forEach(function (row) { var siblings = Array.prototype.filter.call(row.parentNode.children, function (el) { return el.matches('[data-catalog-item]'); }); rows.push({ id: Number(row.getAttribute('data-id')), parent_id: Number(row.getAttribute('data-parent-id')), position: siblings.indexOf(row) }); }); var data = new FormData(); data.append('_csrf', this.csrf()); data.append('order', JSON.stringify(rows)); this.ajax(this.url() + '/reorder', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); }); },
|
||||
markSelectedItem: function (id) {
|
||||
document.querySelectorAll('[data-catalog-builder-item]').forEach(function (row) {
|
||||
row.classList.toggle('is-selected', String(row.getAttribute('data-catalog-builder-item')) === String(id));
|
||||
});
|
||||
},
|
||||
treeRoot: function () { return document.querySelector('[data-catalog-tree]'); },
|
||||
treeNodes: function () {
|
||||
var root = this.treeRoot();
|
||||
return root ? Array.prototype.filter.call(root.children, function (node) { return node.matches('[data-catalog-item]'); }) : [];
|
||||
},
|
||||
search: function (value) {
|
||||
value = String(value || '').trim().toLowerCase();
|
||||
var nodes = this.treeNodes(), byId = {}, visible = {};
|
||||
nodes.forEach(function (node) { byId[String(node.getAttribute('data-id'))] = node; node.classList.add('is-filtered'); });
|
||||
if (!value) {
|
||||
nodes.forEach(function (node) { node.classList.remove('is-filtered'); });
|
||||
} else {
|
||||
nodes.forEach(function (node) {
|
||||
var name = node.querySelector('.catalog-tree-name');
|
||||
if (!name || name.textContent.toLowerCase().indexOf(value) < 0) { return; }
|
||||
var current = node;
|
||||
while (current && !visible[current.getAttribute('data-id')]) {
|
||||
visible[current.getAttribute('data-id')] = true;
|
||||
current = byId[String(current.getAttribute('data-parent-id'))] || null;
|
||||
}
|
||||
});
|
||||
Object.keys(visible).forEach(function (id) { if (byId[id]) { byId[id].classList.remove('is-filtered'); } });
|
||||
}
|
||||
var root = this.treeRoot();
|
||||
if (root) { root.classList.toggle('is-filtering', !!value); }
|
||||
},
|
||||
treeDragStart: function (e) {
|
||||
var handle = e.target.closest('[data-catalog-drag-handle]');
|
||||
var root = handle ? handle.closest('[data-catalog-tree]') : null;
|
||||
if (!handle || !root || handle.disabled || this.orderSaving || root.classList.contains('is-filtering') || (typeof e.button === 'number' && e.button !== 0)) { return; }
|
||||
this.dragItem = handle.closest('[data-catalog-item]');
|
||||
if (!this.dragItem) { return; }
|
||||
this.dragPointerId = e.pointerId;
|
||||
this.dragStartX = e.clientX;
|
||||
this.dragStartY = e.clientY;
|
||||
this.dragStarted = false;
|
||||
this.dragOrderSnapshot = this.treeOrderSignature();
|
||||
if (handle.setPointerCapture) { handle.setPointerCapture(e.pointerId); }
|
||||
e.preventDefault();
|
||||
},
|
||||
beginTreeDrag: function (e) {
|
||||
if (!this.dragItem || this.dragStarted) { return; }
|
||||
var row = this.dragItem.querySelector('[data-catalog-builder-item]');
|
||||
var firstRect = this.dragItem.getBoundingClientRect();
|
||||
var rowRect = row.getBoundingClientRect();
|
||||
this.dragStarted = true;
|
||||
this.dragGroup = this.treeBranch(this.dragItem);
|
||||
this.dragPlaceholder = document.createElement('li');
|
||||
this.dragPlaceholder.className = 'catalog-tree-placeholder';
|
||||
this.dragPlaceholder.setAttribute('aria-hidden', 'true');
|
||||
var lastRect = this.dragGroup[this.dragGroup.length - 1].getBoundingClientRect();
|
||||
this.dragPlaceholder.style.height = Math.max(52, lastRect.bottom - firstRect.top) + 'px';
|
||||
this.dragItem.parentNode.insertBefore(this.dragPlaceholder, this.dragItem);
|
||||
this.dragGhost = row.cloneNode(true);
|
||||
this.dragGhost.classList.remove('is-selected');
|
||||
this.dragGhost.classList.add('catalog-tree-ghost');
|
||||
this.dragGhost.style.width = Math.min(rowRect.width, 520, Math.max(1, window.innerWidth - 24)) + 'px';
|
||||
document.body.appendChild(this.dragGhost);
|
||||
this.positionTreeDragGhost(e);
|
||||
document.body.classList.add('catalog-tree-dragging');
|
||||
this.treeRoot().classList.add('is-drag-active');
|
||||
this.dragGroup.forEach(function (node, index) { node.classList.add(index === 0 ? 'is-dragging' : 'is-dragging-child'); });
|
||||
},
|
||||
treeDragOver: function (e) {
|
||||
if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; }
|
||||
if (!this.dragStarted) {
|
||||
if (Math.abs(e.clientX - this.dragStartX) < 5 && Math.abs(e.clientY - this.dragStartY) < 5) { return; }
|
||||
this.beginTreeDrag(e);
|
||||
}
|
||||
e.preventDefault();
|
||||
this.positionTreeDragGhost(e);
|
||||
this.scrollTree(e.clientY);
|
||||
var target = null, bottom = false, self = this;
|
||||
this.treeNodes().some(function (node) {
|
||||
if (self.dragGroup.indexOf(node) !== -1 || node.classList.contains('is-filtered')) { return false; }
|
||||
var candidate = node.querySelector('[data-catalog-builder-item]');
|
||||
var rect = candidate ? candidate.getBoundingClientRect() : null;
|
||||
if (!rect || e.clientY >= rect.bottom) { return false; }
|
||||
target = node;
|
||||
bottom = e.clientY > rect.top + rect.height / 2;
|
||||
return true;
|
||||
});
|
||||
document.querySelectorAll('.catalog-tree-row.drag-over-top, .catalog-tree-row.drag-over-bottom').forEach(function (row) { row.classList.remove('drag-over-top', 'drag-over-bottom'); });
|
||||
if (!target) { this.treeRoot().appendChild(this.dragPlaceholder); return; }
|
||||
var targetRow = target.querySelector('[data-catalog-builder-item]');
|
||||
var branch = this.treeBranch(target);
|
||||
var anchor = bottom ? branch[branch.length - 1].nextSibling : target;
|
||||
if (anchor !== this.dragPlaceholder) { target.parentNode.insertBefore(this.dragPlaceholder, anchor); }
|
||||
targetRow.classList.toggle('drag-over-top', !bottom);
|
||||
targetRow.classList.toggle('drag-over-bottom', bottom);
|
||||
},
|
||||
treeDrop: function (e) {
|
||||
if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; }
|
||||
e.preventDefault();
|
||||
if (!this.dragStarted || !this.dragPlaceholder || !this.dragPlaceholder.parentNode) { this.treeDragEnd(); return; }
|
||||
var placeholder = this.dragPlaceholder;
|
||||
this.dragGroup.forEach(function (node) { placeholder.parentNode.insertBefore(node, placeholder); });
|
||||
placeholder.parentNode.removeChild(placeholder);
|
||||
this.dragPlaceholder = null;
|
||||
this.normalizeTreeHierarchy();
|
||||
var changed = this.treeOrderSignature() !== this.dragOrderSnapshot;
|
||||
this.treeDragEnd();
|
||||
if (changed) { this.persistTreeOrder(); }
|
||||
},
|
||||
treeDragEnd: function () {
|
||||
this.dragGroup.forEach(function (node) { node.classList.remove('is-dragging', 'is-dragging-child'); });
|
||||
if (this.dragPlaceholder && this.dragPlaceholder.parentNode) { this.dragPlaceholder.parentNode.removeChild(this.dragPlaceholder); }
|
||||
document.querySelectorAll('.catalog-tree-row.drag-over-top, .catalog-tree-row.drag-over-bottom').forEach(function (row) { row.classList.remove('drag-over-top', 'drag-over-bottom'); });
|
||||
var root = this.treeRoot();
|
||||
if (root) { root.classList.remove('is-drag-active'); }
|
||||
if (this.dragGhost && this.dragGhost.parentNode) { this.dragGhost.parentNode.removeChild(this.dragGhost); }
|
||||
document.body.classList.remove('catalog-tree-dragging');
|
||||
this.dragItem = null;
|
||||
this.dragGroup = [];
|
||||
this.dragPlaceholder = null;
|
||||
this.dragGhost = null;
|
||||
this.dragPointerId = null;
|
||||
this.dragStarted = false;
|
||||
this.dragOrderSnapshot = '';
|
||||
},
|
||||
positionTreeDragGhost: function (e) {
|
||||
if (!this.dragGhost) { return; }
|
||||
var left = Math.min(e.clientX + 14, window.innerWidth - this.dragGhost.offsetWidth - 12);
|
||||
var top = Math.min(e.clientY + 12, window.innerHeight - this.dragGhost.offsetHeight - 12);
|
||||
this.dragGhost.style.transform = 'translate3d(' + Math.max(12, left) + 'px,' + Math.max(12, top) + 'px,0)';
|
||||
},
|
||||
scrollTree: function (clientY) {
|
||||
var edge = 72;
|
||||
if (clientY < edge) { window.scrollBy(0, -Math.ceil((edge - clientY) / 5)); }
|
||||
if (clientY > window.innerHeight - edge) { window.scrollBy(0, Math.ceil((clientY - window.innerHeight + edge) / 5)); }
|
||||
},
|
||||
treeOrderSignature: function () {
|
||||
return this.treeNodes().map(function (node) { return node.getAttribute('data-id') + ':' + node.getAttribute('data-level'); }).join('|');
|
||||
},
|
||||
treeBranch: function (node) {
|
||||
if (!node) { return []; }
|
||||
var branch = [node], level = parseInt(node.getAttribute('data-level'), 10) || 0, next = node.nextElementSibling;
|
||||
while (next) {
|
||||
if (next === this.dragPlaceholder) { next = next.nextElementSibling; continue; }
|
||||
if (!next.matches('[data-catalog-item]') || (parseInt(next.getAttribute('data-level'), 10) || 0) <= level) { break; }
|
||||
branch.push(next);
|
||||
next = next.nextElementSibling;
|
||||
}
|
||||
return branch;
|
||||
},
|
||||
canIndentItem: function (node) {
|
||||
var level = parseInt(node.getAttribute('data-level'), 10) || 0, previous = node.previousElementSibling;
|
||||
while (previous) {
|
||||
if (!previous.matches('[data-catalog-item]')) { previous = previous.previousElementSibling; continue; }
|
||||
var previousLevel = parseInt(previous.getAttribute('data-level'), 10) || 0;
|
||||
if (previousLevel < level) { return false; }
|
||||
if (previousLevel === level) { return true; }
|
||||
previous = previous.previousElementSibling;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
changeItemLevel: function (node, delta) {
|
||||
if (!node || !delta || this.orderSaving) { return; }
|
||||
var root = this.treeRoot();
|
||||
if (root && root.classList.contains('is-filtering')) { Adminx.Toast.show('Очистите поиск перед изменением структуры', 'info'); return; }
|
||||
var level = parseInt(node.getAttribute('data-level'), 10) || 0;
|
||||
if (delta > 0 && !this.canIndentItem(node)) { return; }
|
||||
if (delta < 0 && level === 0) { return; }
|
||||
var shift = delta > 0 ? 1 : -1;
|
||||
this.treeBranch(node).forEach(function (branchNode) {
|
||||
var branchLevel = parseInt(branchNode.getAttribute('data-level'), 10) || 0;
|
||||
branchNode.setAttribute('data-level', String(Math.max(0, branchLevel + shift)));
|
||||
});
|
||||
this.normalizeTreeHierarchy();
|
||||
this.persistTreeOrder();
|
||||
},
|
||||
normalizeTreeHierarchy: function () {
|
||||
var nodes = this.treeNodes(), parents = [], positions = {}, previousLevel = 0, self = this;
|
||||
nodes.forEach(function (node, index) {
|
||||
var level = Math.max(0, parseInt(node.getAttribute('data-level'), 10) || 0);
|
||||
if (index === 0) { level = 0; }
|
||||
if (level > previousLevel + 1) { level = previousLevel + 1; }
|
||||
var parentId = level > 0 && parents[level - 1] ? parents[level - 1] : 0;
|
||||
if (level > 0 && !parentId) { level = 0; parentId = 0; }
|
||||
var id = parseInt(node.getAttribute('data-id'), 10) || 0;
|
||||
var key = String(parentId);
|
||||
var position = positions[key] || 0;
|
||||
positions[key] = position + 1;
|
||||
parents[level] = id;
|
||||
parents.length = level + 1;
|
||||
previousLevel = level;
|
||||
node.setAttribute('data-level', String(level));
|
||||
node.setAttribute('data-parent-id', String(parentId));
|
||||
node.setAttribute('data-position', String(position));
|
||||
node.style.setProperty('--catalog-indent', Math.min(level, 10) * 28 + 'px');
|
||||
var number = node.querySelector('.catalog-tree-position');
|
||||
if (number) { number.textContent = String(position + 1); }
|
||||
var badge = node.querySelector('[data-catalog-level-badge]');
|
||||
if (badge) { badge.textContent = 'уровень ' + (level + 1); }
|
||||
});
|
||||
nodes.forEach(function (node) {
|
||||
var level = parseInt(node.getAttribute('data-level'), 10) || 0;
|
||||
var decrease = node.querySelector('[data-catalog-item-indent="-1"]');
|
||||
var increase = node.querySelector('[data-catalog-item-indent="1"]');
|
||||
if (decrease) { decrease.disabled = level === 0; }
|
||||
if (increase) { increase.disabled = !self.canIndentItem(node); }
|
||||
});
|
||||
},
|
||||
persistTreeOrder: function () {
|
||||
if (this.orderSaving) { return; }
|
||||
this.normalizeTreeHierarchy();
|
||||
var rows = this.treeNodes().map(function (node) {
|
||||
return {
|
||||
id: Number(node.getAttribute('data-id')),
|
||||
parent_id: Number(node.getAttribute('data-parent-id')),
|
||||
position: Number(node.getAttribute('data-position'))
|
||||
};
|
||||
});
|
||||
var data = new FormData(), self = this, root = this.treeRoot();
|
||||
data.append('_csrf', this.csrf());
|
||||
data.append('order', JSON.stringify(rows));
|
||||
this.orderSaving = true;
|
||||
if (root) { root.classList.add('is-order-saving'); }
|
||||
this.ajax(this.url() + '/reorder', data, function (payload) {
|
||||
self.orderSaving = false;
|
||||
if (root) { root.classList.remove('is-order-saving'); }
|
||||
Adminx.Toast.show(payload.message || 'Порядок сохранён', 'success');
|
||||
}, { quiet: true, fail: function () {
|
||||
self.orderSaving = false;
|
||||
if (root) { root.classList.remove('is-order-saving'); }
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
} });
|
||||
},
|
||||
ajax: function (url, data, done, options) { options = options || {}; if (!options.quiet) { Adminx.Loader.show(); } fetch(url, { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) { if (!payload.success) { throw payload; } done(payload); }).catch(function (payload) { if (options.fail) { options.fail(payload); } Adminx.Catalog.error(payload); }).finally(function () { if (!options.quiet) { Adminx.Loader.hide(); } }); },
|
||||
json: function (res) { return res.json().then(function (payload) { if (!res.ok) { throw payload; } return payload; }); },
|
||||
esc: function (value) { var node = document.createElement('div'); node.textContent = String(value == null ? '' : value); return node.innerHTML; },
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{% extends '@adminx/main.twig' %}
|
||||
{% block title %}{{ catalog.rubric_title }} · Каталог{% endblock %}
|
||||
{% macro rows(items, can_manage) %}
|
||||
{% macro rows(items, can_manage, level) %}
|
||||
{% set level = level|default(0) %}
|
||||
{% for item in items %}
|
||||
<li class="catalog-tree-item{{ item.status ? '' : ' is-inactive' }}" data-catalog-item data-id="{{ item.id }}" data-parent-id="{{ item.parent_id }}" data-filter-count="{{ item.filters_use|length }}">
|
||||
<div class="catalog-tree-row">
|
||||
<button class="catalog-drag" type="button" draggable="{{ can_manage ? 'true' : 'false' }}" aria-label="Перетащить" data-tooltip="Изменить порядок"><i class="ti ti-grip-vertical"></i></button>
|
||||
<span class="catalog-tree-branch"><i class="ti ti-corner-down-right"></i></span>
|
||||
<li class="catalog-tree-item{{ item.status ? '' : ' is-inactive' }}" data-catalog-item data-id="{{ item.id }}" data-parent-id="{{ item.parent_id }}" data-position="{{ item.position }}" data-level="{{ level }}" data-filter-count="{{ item.filters_use|length }}" style="--catalog-indent:{{ level > 10 ? 280 : level * 28 }}px">
|
||||
<div class="catalog-tree-row" data-catalog-builder-item="{{ item.id }}">
|
||||
<button class="catalog-drag" type="button" aria-label="Перетащить раздел" data-tooltip="Перетащить раздел вместе с подразделами" data-catalog-drag-handle{{ can_manage ? '' : ' disabled' }}><i class="ti ti-grip-vertical"></i></button>
|
||||
<span class="catalog-tree-position" data-tooltip="Позиция внутри уровня">{{ item.position + 1 }}</span>
|
||||
<div class="catalog-tree-name"><b>{{ item.name }}</b><small>#{{ item.id }}{% if item.document_id %} · документ #{{ item.document_id }}{% endif %}</small></div>
|
||||
<div class="catalog-tree-meta"><span class="badge badge-gray" data-catalog-level-badge>уровень {{ level + 1 }}</span><span class="catalog-tree-count" data-tooltip="Поля / фильтры">{{ item.fields_use|length }} / {{ item.filters_use|length }}</span></div>
|
||||
<div class="catalog-tree-status"><span data-catalog-item-status-label>{{ item.status ? 'активен' : 'скрыт' }}</span><label class="switch" data-tooltip="{{ item.status ? 'Скрыть раздел' : 'Включить раздел' }}"><input type="checkbox" data-catalog-item-status="{{ item.id }}"{{ item.status ? ' checked' : '' }} aria-label="{{ item.status ? 'Скрыть раздел' : 'Включить раздел' }}"></label></div>
|
||||
<span class="catalog-tree-count" data-tooltip="Поля / фильтры">{{ item.fields_use|length }} / {{ item.filters_use|length }}</span>
|
||||
{% if can_manage %}<div class="catalog-tree-actions"><button class="btn btn-ghost btn-icon btn-sm catalog-action-edit" type="button" data-catalog-item-edit="{{ item.id }}" data-tooltip="Редактировать"><i class="ti ti-edit"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-delete" type="button" data-catalog-item-delete="{{ item.id }}" data-tooltip="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}
|
||||
{% if can_manage %}<div class="catalog-tree-actions"><button class="btn btn-ghost btn-icon btn-sm catalog-action-level" type="button" data-catalog-item-indent="-1" data-tooltip="Поднять на уровень выше"{{ level == 0 ? ' disabled' : '' }}><i class="ti ti-arrow-left"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-level" type="button" data-catalog-item-indent="1" data-tooltip="Сделать подразделом"><i class="ti ti-arrow-right"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-sibling" type="button" data-catalog-item-sibling="{{ item.id }}" data-tooltip="Добавить рядом"><i class="ti ti-row-insert-bottom"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-child" type="button" data-catalog-item-child="{{ item.id }}" data-tooltip="Добавить подраздел"><i class="ti ti-subtask"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-edit" type="button" data-catalog-item-edit="{{ item.id }}" data-tooltip="Редактировать"><i class="ti ti-edit"></i></button><button class="btn btn-ghost btn-icon btn-sm catalog-action-delete" type="button" data-catalog-item-delete="{{ item.id }}" data-tooltip="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}
|
||||
</div>
|
||||
{% if item.children %}<ol class="catalog-tree-children">{{ _self.rows(item.children, can_manage) }}</ol>{% endif %}
|
||||
</li>
|
||||
{% if item.children %}{{ _self.rows(item.children, can_manage, level + 1) }}{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% block content %}
|
||||
@@ -25,7 +26,7 @@
|
||||
<div class="section-header catalog-panel-header"><div class="section-icon"><i class="ti ti-list-tree"></i></div><div><div class="section-eyebrow">Структура</div><h2>Разделы каталога</h2><p class="section-desc">Порядок и вложенность используются в каталожном поле документа.</p></div></div>
|
||||
<div class="card catalog-card">
|
||||
<div class="catalog-section-head"><div class="catalog-section-title"><span class="icon-tile catalog-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-tree"></i></span><div><h2>Дерево разделов</h2><p class="text-secondary">Перетаскивайте строки за маркер слева.</p></div></div><div class="catalog-tree-tools"><label class="input-wrap catalog-tree-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-catalog-tree-search></label>{% if can_manage %}<button class="btn btn-secondary" type="button" data-catalog-recompile-all><i class="ti ti-refresh"></i>Пересобрать фильтры</button>{% endif %}</div></div>
|
||||
<ol class="catalog-tree" data-catalog-tree>{{ _self.rows(tree, can_manage) }}</ol>
|
||||
<ol class="catalog-tree" data-catalog-tree>{{ _self.rows(tree, can_manage, 0) }}</ol>
|
||||
{% if not tree %}<div class="empty-state">Разделов пока нет.</div>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -38,7 +39,7 @@
|
||||
<div class="catalog-card-body"><div class="form-grid">
|
||||
<label class="field col-3"><span class="field-label">Назначение каталога</span><select class="select" name="purpose"><option value="content"{{ settings.purpose == 'content' ? ' selected' : '' }}>Обычный каталог</option>{% if products_available %}<option value="commerce"{{ settings.purpose == 'commerce' ? ' selected' : '' }}>Товарный каталог</option>{% endif %}</select><span class="field-hint">{% if products_available %}Товарный режим включает коммерческий индекс, цены, остатки, варианты и фиды.{% else %}Товарный режим доступен после установки модуля «Товары».{% endif %}</span></label>
|
||||
<label class="field col-3"><span class="field-label">Запрос фильтра</span><select class="select" name="request_id"><option value="0">Не выбран</option>{% for item in request_options %}<option value="{{ item.id }}"{{ settings.request_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Запрос, который формирует выдачу и условия фильтрации.</span></label>
|
||||
<label class="field col-3"><span class="field-label">Навигация</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Меню, используемое для экспорта структуры каталога.</span></label>
|
||||
<label class="field col-3"><span class="field-label">Навигация в шапке</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Дополнительные ссылки и подборки перед разделами каталога.</span></label>
|
||||
<label class="field col-3"><span class="field-label">Рубрика разделов</span><select class="select" name="rub_cat_id"><option value="0">Не выбрана</option>{% for item in rubric_options %}<option value="{{ item.id }}"{{ settings.rub_cat_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Рубрика документов, связанных с разделами каталога.</span></label>
|
||||
</div>
|
||||
{% if products_available %}<section class="catalog-commerce-fields" data-catalog-commerce-fields><div class="catalog-behavior-head"><span class="icon-tile catalog-behavior-icon is-green"><i class="ti ti-shopping-bag"></i></span><h3>Поля товарного представления</h3></div><div class="form-grid">{% set commerce_fields={'product_title_field_id':'Название товара','product_article_field_id':'Артикул','product_price_field_id':'Цена','product_old_price_field_id':'Старая цена','product_stock_field_id':'Остаток','product_images_field_id':'Изображения'} %}{% for key,label in commerce_fields %}<label class="field col-4"><span class="field-label">{{ label }}</span><select class="select" name="{{ key }}"><option value="0">Не назначено</option>{% for group in field_groups %}{% for field in group.items %}<option value="{{ field.id }}"{{ attribute(settings,key) == field.id ? ' selected' : '' }}>{{ field.title ?: field.alias }} · #{{ field.id }}</option>{% endfor %}{% endfor %}</select></label>{% endfor %}</div></section>
|
||||
|
||||
@@ -31,14 +31,20 @@
|
||||
{
|
||||
if(!Permission::check('view_customers')){Response::forbidden();return '';}AdminAssets::addStyle($this->base().'/modules/Customers/assets/customers.css',50);AdminAssets::addScript($this->base().'/modules/Customers/assets/customers.js',50);CodeEditor::useCodeMirror('htmlmixed');
|
||||
$oauthModules=Model::oauthModules();foreach($oauthModules as &$oauthModule){$oauthModule['can_open']=Permission::check($oauthModule['permission']);}unset($oauthModule);
|
||||
$tab=Request::getStr('tab','customers');if(!in_array($tab,array('customers','fields','auth','pages'),true)){$tab='customers';}return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers(Request::getStr('q','')),'fields'=>Model::fields(),'stats'=>Model::stats(),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'oauth_modules'=>$oauthModules,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>Request::getStr('q',''),'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users')));
|
||||
$tab=Request::getStr('tab','customers');if(!in_array($tab,array('customers','fields','auth','pages'),true)){$tab='customers';}return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers(Request::getStr('q','')),'fields'=>Model::fields(),'stats'=>Model::stats(),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'oauth_modules'=>$oauthModules,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>Request::getStr('q',''),'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users'),'current_public_user_id'=>Model::publicIdForSystem(Auth::id())));
|
||||
}
|
||||
|
||||
public function toggle(array $params=array())
|
||||
{
|
||||
if(($e=$this->guard())!==null){return $e;}
|
||||
try{$active=Model::toggle(isset($params['id'])?$params['id']:0,Auth::id());}catch(\InvalidArgumentException $e){return $this->error($e->getMessage(),array(),422);}
|
||||
return $this->success($active?'Пользователь включён':'Пользователь отключён');
|
||||
}
|
||||
|
||||
public function toggle(array $params=array()){if(($e=$this->guard())!==null){return $e;}return $this->success(Model::toggle(isset($params['id'])?$params['id']:0)?'Пользователь включён':'Пользователь отключён');}
|
||||
public function customer(array $params=array())
|
||||
{
|
||||
if(!Permission::check('view_customers')){return $this->error('Недостаточно прав',array(),403);}
|
||||
$customer=Model::customer(isset($params['id'])?$params['id']:0);
|
||||
$customer=Model::customer(isset($params['id'])?$params['id']:0,Auth::id());
|
||||
return $customer?$this->success('',array('data'=>$customer)):$this->error('Пользователь не найден',array(),404);
|
||||
}
|
||||
|
||||
@@ -46,11 +52,28 @@
|
||||
{
|
||||
if(($e=$this->guard())!==null){return $e;}
|
||||
$id=isset($params['id'])?(int)$params['id']:0;$input=Request::postAll();
|
||||
if(!Permission::check('manage_users')){$current=Model::customer($id);$input['admin_access']=!empty($current['system']['is_active'])?'1':'';$input['admin_role']=!empty($current['system']['role'])?(string)$current['system']['role']:'manager';}
|
||||
if(!Permission::check('manage_users')){$current=Model::customer($id,Auth::id());$input['admin_access']=!empty($current['system']['is_active'])?'1':'';$input['admin_role']=!empty($current['system']['role'])?(string)$current['system']['role']:'manager';}
|
||||
try{$customer=Model::updateCustomer($id,$input,Auth::id());}catch(\InvalidArgumentException $e){return $this->error($e->getMessage(),array(),422);}catch(\Throwable $e){return $this->error('Не удалось сохранить пользователя',array(),500);}
|
||||
return $this->success('Профиль пользователя сохранён',array('data'=>$customer));
|
||||
}
|
||||
|
||||
public function deleteCustomer(array $params = array())
|
||||
{
|
||||
if (($e = $this->guard()) !== null) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
try {
|
||||
Model::deleteCustomer(isset($params['id']) ? $params['id'] : 0, Auth::id());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), array(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('Не удалось удалить пользователя', array(), 500);
|
||||
}
|
||||
|
||||
return $this->success('Пользователь удалён', array('reload' => true));
|
||||
}
|
||||
|
||||
public function saveField(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$id=Model::saveField(isset($params['id'])?$params['id']:0,Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Поле сохранено',array('data'=>array('id'=>$id),'reload'=>true));}
|
||||
public function deleteField(array $params=array()){if(($e=$this->guard())!==null){return $e;}Model::deleteField(isset($params['id'])?$params['id']:0);return $this->success('Поле удалено',array('reload'=>true));}
|
||||
public function toggleField(array $params=array()){if(($e=$this->guard())!==null){return $e;}$active=Model::toggleField(isset($params['id'])?$params['id']:0);return $this->success($active?'Поле включено':'Поле скрыто',array('data'=>array('is_active'=>$active?1:0)));}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
use App\Adminx\Support\Roles;
|
||||
use App\Common\Auth\IdentityLinker;
|
||||
use App\Common\ModuleManager;
|
||||
use App\Common\SystemTables;
|
||||
use App\Content\PublicUserTables;
|
||||
use App\Content\ContentTables;
|
||||
use App\Common\PublicAuthSettings;
|
||||
@@ -43,15 +44,24 @@
|
||||
$table=self::table('users');return array('total'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s','1')->getValue(),'active'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND status=%s','1','1')->getValue(),'verified'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND (email_verified_at>0 OR phone_verified_at>0)','1')->getValue(),'fields'=>count(self::fields()));
|
||||
}
|
||||
|
||||
public static function toggle($id)
|
||||
public static function toggle($id, $currentSystemId = 0)
|
||||
{
|
||||
$customer = self::customer($id, $currentSystemId);
|
||||
if (!$customer) {
|
||||
throw new \InvalidArgumentException('Пользователь не найден');
|
||||
}
|
||||
|
||||
if (!empty($customer['is_current'])) {
|
||||
throw new \InvalidArgumentException('Нельзя отключить собственную учётную запись');
|
||||
}
|
||||
|
||||
DB::query('UPDATE '.self::table('users')." SET status=IF(status='1','0','1') WHERE Id=%i AND deleted!=%s",(int)$id,'1');
|
||||
$active=(string)DB::query('SELECT status FROM '.self::table('users').' WHERE Id=%i',(int)$id)->getValue()==='1';
|
||||
if(!$active){self::invalidateCustomerSessions((int)$id);}
|
||||
return $active;
|
||||
}
|
||||
|
||||
public static function customer($id)
|
||||
public static function customer($id, $currentSystemId = 0)
|
||||
{
|
||||
$row=DB::query('SELECT Id AS id,email,email_verified_at,firstname,lastname,user_name,phone,phone_normalized,phone_verified_at,company,city,street,street_nr,zipcode,birthday,description,user_group,status,reg_time,last_visit FROM '.self::table('users').' WHERE Id=%i AND deleted!=%s LIMIT 1',(int)$id,'1')->getAssoc();
|
||||
if(!$row){return null;}
|
||||
@@ -63,14 +73,43 @@
|
||||
$system=IdentityLinker::systemForPublic((int)$id,(string)$row['email']);
|
||||
return array('user'=>$row,'extra'=>$extra,'system'=>$system?array(
|
||||
'id'=>(int)$system['id'],'role'=>(string)$system['role'],'is_active'=>(int)$system['is_active'],
|
||||
):null);
|
||||
):null,'is_current'=>$system&&(int)$system['id']===(int)$currentSystemId);
|
||||
}
|
||||
|
||||
public static function publicIdForSystem($systemId)
|
||||
{
|
||||
$system = DB::query(
|
||||
'SELECT * FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1',
|
||||
(int) $systemId
|
||||
)->getAssoc();
|
||||
if (!$system) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$public = IdentityLinker::publicForSystem((array) $system);
|
||||
return $public ? (int) $public['Id'] : 0;
|
||||
}
|
||||
|
||||
public static function updateCustomer($id,array $input,$currentSystemId=0)
|
||||
{
|
||||
$id=(int)$id;
|
||||
$current=self::customer($id);
|
||||
$current=self::customer($id,$currentSystemId);
|
||||
if(!$current){throw new \InvalidArgumentException('Пользователь не найден');}
|
||||
if(!empty($current['is_current'])){
|
||||
$currentSystem=$current['system'];
|
||||
if(isset($input['user_group'])&&(int)$input['user_group']!==(int)$current['user']['user_group']){
|
||||
throw new \InvalidArgumentException('Нельзя изменить собственную публичную группу');
|
||||
}
|
||||
|
||||
if(isset($input['admin_role'])&&(string)$input['admin_role']!==(string)$currentSystem['role']){
|
||||
throw new \InvalidArgumentException('Нельзя изменить собственную роль в панели управления');
|
||||
}
|
||||
|
||||
$input['user_group']=(string)$current['user']['user_group'];
|
||||
$input['status']=(string)$current['user']['status']==='1'?'1':'';
|
||||
$input['admin_access']=!empty($currentSystem['is_active'])?'1':'';
|
||||
$input['admin_role']=(string)$currentSystem['role'];
|
||||
}
|
||||
|
||||
$email=mb_strtolower(trim((string)(isset($input['email'])?$input['email']:'')));
|
||||
$phoneInput=trim((string)(isset($input['phone'])?$input['phone']:''));
|
||||
@@ -132,6 +171,40 @@
|
||||
return self::customer($id);
|
||||
}
|
||||
|
||||
public static function deleteCustomer($id, $currentSystemId = 0)
|
||||
{
|
||||
$id = (int) $id;
|
||||
$current = self::customer($id, $currentSystemId);
|
||||
if (!$current) {
|
||||
throw new \InvalidArgumentException('Пользователь не найден');
|
||||
}
|
||||
|
||||
if (!empty($current['is_current'])) {
|
||||
throw new \InvalidArgumentException('Нельзя удалить собственную учётную запись');
|
||||
}
|
||||
|
||||
if (!empty($current['system']['is_active'])) {
|
||||
throw new \InvalidArgumentException('Сначала отключите пользователю доступ к панели управления');
|
||||
}
|
||||
|
||||
DB::startTransaction();
|
||||
try {
|
||||
DB::Update(self::table('users'), array(
|
||||
'status' => '0',
|
||||
'deleted' => '1',
|
||||
'del_time' => time(),
|
||||
), 'Id = %i', $id);
|
||||
self::invalidateCustomerSessions($id);
|
||||
DB::Delete(self::table('user_identities'), 'user_id = %i', $id);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function fields()
|
||||
{
|
||||
$rows = DB::query('SELECT * FROM '.self::table('user_profile_fields').' ORDER BY position,id')->getAll() ?: array();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -101,6 +97,16 @@
|
||||
.customers-editor-access .customers-switch-card:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.customers-editor-input {
|
||||
width: 100%;
|
||||
}
|
||||
.customers-editor-input .input {
|
||||
width: 100%;
|
||||
}
|
||||
.customers-switch-card.is-locked {
|
||||
opacity: 0.68;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.customers-editor-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
document.addEventListener('click', function (event) {
|
||||
var customerEdit = event.target.closest('[data-customer-edit]');
|
||||
if (customerEdit) { event.preventDefault(); self.openCustomer(customerEdit); return; }
|
||||
var customerDelete = event.target.closest('[data-customer-delete]');
|
||||
if (customerDelete) { event.preventDefault(); self.deleteCustomer(customerDelete); return; }
|
||||
if (event.target.closest('[data-field-new]')) { self.fieldForm(null); }
|
||||
var edit = event.target.closest('[data-field-edit]');
|
||||
if (edit) { self.fieldForm(JSON.parse(edit.closest('[data-field]').getAttribute('data-field'))); }
|
||||
@@ -135,6 +137,7 @@
|
||||
if (!form) { return; }
|
||||
form.reset();
|
||||
form.dataset.id = user.id || '0';
|
||||
form.dataset.current = data.is_current ? '1' : '0';
|
||||
['firstname', 'lastname', 'email', 'user_name', 'phone', 'company', 'birthday', 'description', 'city', 'street', 'street_nr', 'zipcode', 'user_group'].forEach(function (name) {
|
||||
if (form.elements[name]) { form.elements[name].value = user[name] == null ? '' : user[name]; }
|
||||
});
|
||||
@@ -145,6 +148,7 @@
|
||||
form.elements.phone_verified.checked = Number(user.phone_verified_at) > 0;
|
||||
form.elements.admin_access.checked = !!(data.system && Number(data.system.is_active) === 1);
|
||||
form.elements.admin_role.value = data.system && data.system.role ? data.system.role : 'manager';
|
||||
this.syncCurrentAccountProtection();
|
||||
this.syncAdminAccess();
|
||||
Object.keys(extra).forEach(function (id) {
|
||||
var input = form.elements['extra[' + id + ']'];
|
||||
@@ -179,9 +183,51 @@
|
||||
var form = document.querySelector('[data-customer-editor]');
|
||||
var field = form ? form.querySelector('[data-customer-admin-role]') : null;
|
||||
var toggle = form && form.elements.admin_access ? form.elements.admin_access : null;
|
||||
var isCurrent = form && form.dataset.current === '1';
|
||||
var canManage = form && form.dataset.canManageAdminAccess === '1';
|
||||
if (!field || !toggle) { return; }
|
||||
field.hidden = !toggle.checked;
|
||||
if (form.elements.admin_role) { form.elements.admin_role.disabled = !toggle.checked; }
|
||||
if (form.elements.admin_role) { form.elements.admin_role.disabled = !toggle.checked || !canManage || isCurrent; }
|
||||
},
|
||||
|
||||
syncCurrentAccountProtection: function () {
|
||||
var form = document.querySelector('[data-customer-editor]');
|
||||
var isCurrent = form && form.dataset.current === '1';
|
||||
var canManage = form && form.dataset.canManageAdminAccess === '1';
|
||||
var accountHint = form ? form.querySelector('[data-customer-account-hint]') : null;
|
||||
var adminHint = form ? form.querySelector('[data-customer-admin-hint]') : null;
|
||||
var groupHint = form ? form.querySelector('[data-customer-group-hint]') : null;
|
||||
var roleHint = form ? form.querySelector('[data-customer-admin-role-hint]') : null;
|
||||
var accountCard = form ? form.querySelector('[data-customer-account-card]') : null;
|
||||
var adminCard = form ? form.querySelector('[data-customer-admin-card]') : null;
|
||||
if (!form) { return; }
|
||||
form.elements.user_group.disabled = isCurrent;
|
||||
form.elements.status.disabled = isCurrent;
|
||||
form.elements.admin_access.disabled = isCurrent || !canManage;
|
||||
if (accountCard) { accountCard.classList.toggle('is-locked', isCurrent); }
|
||||
if (adminCard) { adminCard.classList.toggle('is-locked', isCurrent || !canManage); }
|
||||
if (accountHint) {
|
||||
accountHint.textContent = isCurrent
|
||||
? 'Собственную учётную запись нельзя отключить.'
|
||||
: 'Может входить на сайт и в личный кабинет.';
|
||||
}
|
||||
if (adminHint) {
|
||||
adminHint.textContent = isCurrent
|
||||
? 'Собственный доступ к панели нельзя отключить.'
|
||||
: (canManage
|
||||
? 'Та же учётная запись сможет работать в административном интерфейсе.'
|
||||
: 'Изменение требует права управления системными пользователями.');
|
||||
}
|
||||
if (groupHint) {
|
||||
groupHint.textContent = isCurrent
|
||||
? 'Собственную публичную группу изменяйте через другого администратора.'
|
||||
: 'Определяет права пользователя на публичной части сайта.';
|
||||
}
|
||||
if (roleHint) {
|
||||
roleHint.textContent = isCurrent
|
||||
? 'Собственную роль изменяйте через другого администратора.'
|
||||
: 'Права роли настраиваются в разделе «Роли и права».';
|
||||
}
|
||||
},
|
||||
|
||||
formatTimestamp: function (value) {
|
||||
@@ -206,6 +252,22 @@
|
||||
});
|
||||
},
|
||||
|
||||
deleteCustomer: function (button) {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'error',
|
||||
title: 'Удалить пользователя сайта?',
|
||||
message: 'Аккаунт будет отключён, а его публичные сессии завершены. История заказов и связанные записи сохранятся.',
|
||||
confirmLabel: 'Удалить',
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: function () {
|
||||
self.request(button.getAttribute('data-url'), new FormData()).then(function () {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleField: function (input) {
|
||||
var self = this;
|
||||
var item = input.closest('[data-field-id]');
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
return array(
|
||||
'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.3.0',
|
||||
'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.3.1',
|
||||
'permissions' => array('key' => 'customers', 'items' => array(
|
||||
array(
|
||||
'code' => 'view_customers',
|
||||
@@ -48,6 +48,7 @@
|
||||
array('GET', '/system/customers', array(\App\Adminx\Customers\Controller::class, 'index')),
|
||||
array('GET', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'customer')),
|
||||
array('POST', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'updateCustomer')),
|
||||
array('POST', '/system/customers/users/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteCustomer')),
|
||||
array('POST', '/system/customers/{id}/toggle', array(\App\Adminx\Customers\Controller::class, 'toggle')),
|
||||
array('POST', '/system/customers/fields/reorder', array(\App\Adminx\Customers\Controller::class, 'reorderFields')),
|
||||
array('POST', '/system/customers/fields/{id}', array(\App\Adminx\Customers\Controller::class, 'saveField')),
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="card ax-list-card">
|
||||
<div class="ax-list-head"><div class="ax-list-title"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-users-group"></i></span><div><h2>Публичные аккаунты</h2><p class="text-secondary">Посетители, зарегистрированные на сайте, их контакты и доступ к личному кабинету.</p></div></div><span class="badge badge-blue">{{ customers|length }}</span></div>
|
||||
<div class="table-scroll"><table class="table customers-table"><thead><tr><th>ID</th><th>Пользователь</th><th>Контакты</th><th>Компания</th><th>Регистрация</th><th>Статус</th>{% if can_manage %}<th class="customers-actions-col">Действия</th>{% endif %}</tr></thead><tbody>
|
||||
{% for customer in customers %}<tr data-customer-row data-id="{{ customer.id }}"><td class="mono text-muted">{{ customer.id }}</td><td data-customer-name><b>{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}</b><small class="mono">{{ customer.user_name }}</small></td><td data-customer-contacts><span>{{ customer.email ?: customer.phone }}</span>{% if customer.email and customer.phone %}<small>{{ customer.phone }}</small>{% endif %}</td><td data-customer-company>{{ customer.company ?: '—' }}</td><td class="mono text-sm">{{ customer.reg_time ? customer.reg_time|date('d.m.Y') : '—' }}</td><td>{% if can_manage %}<label class="switch" data-tooltip="Изменить активность"><input type="checkbox" data-customer-toggle data-url="{{ ADMINX_BASE }}/system/customers/{{ customer.id }}/toggle"{{ customer.status=='1'?' checked':'' }}><span></span></label>{% else %}<span class="badge {{ customer.status=='1'?'badge-green':'badge-gray' }}">{{ customer.status=='1'?'Активен':'Отключён' }}</span>{% endif %}</td>{% if can_manage %}<td><button class="btn btn-icon btn-ghost ax-act ax-act-edit" type="button" data-customer-edit data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}" data-tooltip="Редактировать" aria-label="Редактировать"><i class="ti ti-pencil"></i></button></td>{% endif %}</tr>
|
||||
{% for customer in customers %}<tr data-customer-row data-id="{{ customer.id }}"><td class="mono text-muted">{{ customer.id }}</td><td data-customer-name><b>{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}</b><small class="mono">{{ customer.user_name }}</small></td><td data-customer-contacts><span>{{ customer.email ?: customer.phone }}</span>{% if customer.email and customer.phone %}<small>{{ customer.phone }}</small>{% endif %}</td><td data-customer-company>{{ customer.company ?: '—' }}</td><td class="mono text-sm">{{ customer.reg_time ? customer.reg_time|date('d.m.Y') : '—' }}</td><td>{% if can_manage %}<label class="switch" data-tooltip="{{ customer.id==current_public_user_id ? 'Собственную учётную запись нельзя отключить' : 'Изменить активность' }}"><input type="checkbox" data-customer-toggle data-url="{{ ADMINX_BASE }}/system/customers/{{ customer.id }}/toggle"{{ customer.status=='1'?' checked':'' }}{{ customer.id==current_public_user_id?' disabled':'' }}><span></span></label>{% else %}<span class="badge {{ customer.status=='1'?'badge-green':'badge-gray' }}">{{ customer.status=='1'?'Активен':'Отключён' }}</span>{% endif %}</td>{% if can_manage %}<td><button class="btn btn-icon btn-ghost ax-act ax-act-edit" type="button" data-customer-edit data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}" data-tooltip="Редактировать" aria-label="Редактировать"><i class="ti ti-pencil"></i></button>{% if customer.id==current_public_user_id %}<button class="btn btn-icon btn-ghost ax-act" type="button" disabled data-tooltip="Собственную учётную запись удалить нельзя" aria-label="Удаление недоступно"><i class="ti ti-lock"></i></button>{% else %}<button class="btn btn-icon btn-ghost ax-act ax-act-danger" type="button" data-customer-delete data-url="{{ ADMINX_BASE }}/system/customers/users/{{ customer.id }}/delete" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>{% endif %}</td>{% endif %}</tr>
|
||||
{% else %}<tr><td colspan="{{ can_manage ? 7 : 6 }}"><div class="empty-state">Пользователи не найдены</div></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
@@ -173,27 +173,27 @@
|
||||
{% if can_manage %}
|
||||
<aside class="drawer drawer-right customer-editor-drawer" id="customerEditorDrawer" role="dialog" aria-modal="true" aria-labelledby="customerEditorTitle" hidden>
|
||||
<div class="drawer-header"><div><h3 id="customerEditorTitle" data-customer-editor-title>Пользователь сайта</h3><p>Профиль, доступ и данные личного кабинета</p></div><button class="btn btn-icon btn-ghost" type="button" data-close-drawer aria-label="Закрыть"><i class="ti ti-x"></i></button></div>
|
||||
<form data-customer-editor data-base="{{ ADMINX_BASE }}">
|
||||
<form data-customer-editor data-base="{{ ADMINX_BASE }}" data-can-manage-admin-access="{{ can_manage_admin_access ? '1' : '0' }}">
|
||||
<div class="drawer-body">
|
||||
<div class="customers-editor-meta"><span><i class="ti ti-id"></i><b data-customer-meta-id>#—</b></span><span><i class="ti ti-user-plus"></i><b data-customer-meta-created>—</b><small>Регистрация</small></span><span><i class="ti ti-clock"></i><b data-customer-meta-visit>—</b><small>Последний вход</small></span></div>
|
||||
<div class="customers-editor-sections">
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-user"></i></span><div><b>Основные данные</b><span>Имя, контакты и данные организации.</span></div></div><div class="form-grid"><label class="field col-6"><span class="field-label">Имя</span><input class="input" name="firstname" maxlength="50"></label><label class="field col-6"><span class="field-label">Фамилия</span><input class="input" name="lastname" maxlength="50"></label><label class="field col-6"><span class="field-label">Email</span><input class="input" type="email" name="email" maxlength="100" required></label><label class="field col-6"><span class="field-label">Логин</span><input class="input mono" name="user_name" maxlength="50" required><span class="field-hint">Можно использовать для входа наравне с email.</span></label><label class="field col-6"><span class="field-label">Телефон</span><input class="input" type="tel" name="phone" maxlength="50"></label><label class="field col-6"><span class="field-label">Компания</span><input class="input" name="company" maxlength="255"></label><label class="field col-4"><span class="field-label">Дата рождения</span><input class="input" type="date" name="birthday"></label><label class="field col-8"><span class="field-label">О пользователе</span><textarea class="textarea" name="description" rows="2"></textarea></label></div></section>
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-map-pin"></i></span><div><b>Адрес</b><span>Данные для профиля и предзаполнения заказов.</span></div></div><div class="form-grid"><label class="field col-4"><span class="field-label">Город</span><input class="input" name="city"></label><label class="field col-4"><span class="field-label">Улица</span><input class="input" name="street"></label><label class="field col-2"><span class="field-label">Дом</span><input class="input" name="street_nr"></label><label class="field col-2"><span class="field-label">Индекс</span><input class="input" name="zipcode"></label></div></section>
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-user"></i></span><div><b>Основные данные</b><span>Имя, контакты и данные организации.</span></div></div><div class="form-grid"><label class="field col-6"><span class="field-label">Имя</span><span class="input-wrap customers-editor-input"><i class="ti ti-user"></i><input class="input" name="firstname" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Фамилия</span><span class="input-wrap customers-editor-input"><i class="ti ti-user"></i><input class="input" name="lastname" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Email</span><span class="input-wrap customers-editor-input"><i class="ti ti-mail"></i><input class="input" type="email" name="email" maxlength="100" required></span></label><label class="field col-6"><span class="field-label">Логин</span><span class="input-wrap customers-editor-input"><i class="ti ti-at"></i><input class="input mono" name="user_name" maxlength="50" required></span><span class="field-hint">Можно использовать для входа наравне с email.</span></label><label class="field col-6"><span class="field-label">Телефон</span><span class="input-wrap customers-editor-input"><i class="ti ti-phone"></i><input class="input" type="tel" name="phone" maxlength="50"></span></label><label class="field col-6"><span class="field-label">Компания</span><span class="input-wrap customers-editor-input"><i class="ti ti-building"></i><input class="input" name="company" maxlength="255"></span></label><label class="field col-4"><span class="field-label">Дата рождения</span><span class="input-wrap customers-editor-input"><i class="ti ti-calendar"></i><input class="input" type="date" name="birthday"></span></label><label class="field col-8"><span class="field-label">О пользователе</span><textarea class="textarea" name="description" rows="2"></textarea></label></div></section>
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-map-pin"></i></span><div><b>Адрес</b><span>Данные для профиля и предзаполнения заказов.</span></div></div><div class="form-grid"><label class="field col-4"><span class="field-label">Город</span><span class="input-wrap customers-editor-input"><i class="ti ti-building-community"></i><input class="input" name="city"></span></label><label class="field col-4"><span class="field-label">Улица</span><span class="input-wrap customers-editor-input"><i class="ti ti-road"></i><input class="input" name="street"></span></label><label class="field col-2"><span class="field-label">Дом</span><span class="input-wrap customers-editor-input"><i class="ti ti-home"></i><input class="input" name="street_nr"></span></label><label class="field col-2"><span class="field-label">Индекс</span><span class="input-wrap customers-editor-input"><i class="ti ti-mailbox"></i><input class="input" name="zipcode"></span></label></div></section>
|
||||
<section class="customers-form-section">
|
||||
<div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-shield-check"></i></span><div><b>Доступ</b><span>Публичная группа, состояние аккаунта и роль в панели управления.</span></div></div>
|
||||
<div class="form-grid">
|
||||
<label class="field col-6"><span class="field-label">Группа публичных пользователей</span><select class="select" name="user_group" required>{% for group in customer_groups %}<option value="{{ group.id }}">#{{ group.id }} · {{ group.name }} · {{ group.permissions_count }} разрешений</option>{% endfor %}</select></label>
|
||||
<label class="field col-6" data-customer-admin-role><span class="field-label">Роль в панели управления</span><select class="select" name="admin_role"{{ can_manage_admin_access ? '' : ' disabled' }}>{% for code, label in admin_roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select><span class="field-hint">Права роли настраиваются в разделе «Роли и права».</span></label>
|
||||
<label class="field col-6"><span class="field-label">Группа публичных пользователей</span><select class="select" name="user_group" required>{% for group in customer_groups %}<option value="{{ group.id }}">#{{ group.id }} · {{ group.name }} · {{ group.permissions_count }} разрешений</option>{% endfor %}</select><span class="field-hint" data-customer-group-hint>Определяет права пользователя на публичной части сайта.</span></label>
|
||||
<label class="field col-6" data-customer-admin-role><span class="field-label">Роль в панели управления</span><select class="select" name="admin_role"{{ can_manage_admin_access ? '' : ' disabled' }}>{% for code, label in admin_roles %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select><span class="field-hint" data-customer-admin-role-hint>Права роли настраиваются в разделе «Роли и права».</span></label>
|
||||
</div>
|
||||
<div class="customers-switch-grid customers-editor-access">
|
||||
<label class="customers-switch-card"><span><b>Аккаунт включён</b><small>Может входить на сайт и в личный кабинет.</small></span><span class="switch"><input type="checkbox" name="status" value="1"><span></span></span></label>
|
||||
<label class="customers-switch-card"><span><b>Доступ в панель управления</b><small>{{ can_manage_admin_access ? 'Та же учётная запись сможет работать в административном интерфейсе.' : 'Изменение требует права управления системными пользователями.' }}</small></span><span class="switch"><input type="checkbox" name="admin_access" value="1" data-customer-admin-access{{ can_manage_admin_access ? '' : ' disabled' }}><span></span></span></label>
|
||||
<label class="customers-switch-card" data-customer-account-card><span><b>Аккаунт включён</b><small data-customer-account-hint>Может входить на сайт и в личный кабинет.</small></span><span class="switch"><input type="checkbox" name="status" value="1"><span></span></span></label>
|
||||
<label class="customers-switch-card" data-customer-admin-card><span><b>Доступ в панель управления</b><small data-customer-admin-hint>{{ can_manage_admin_access ? 'Та же учётная запись сможет работать в административном интерфейсе.' : 'Изменение требует права управления системными пользователями.' }}</small></span><span class="switch"><input type="checkbox" name="admin_access" value="1" data-customer-admin-access{{ can_manage_admin_access ? '' : ' disabled' }}><span></span></span></label>
|
||||
<label class="customers-switch-card"><span><b>Email подтверждён</b><small>Администратор может изменить статус вручную.</small></span><span class="switch"><input type="checkbox" name="email_verified" value="1"><span></span></span></label>
|
||||
<label class="customers-switch-card"><span><b>Телефон подтверждён</b><small>Подготовлено для будущего SMS-гейта.</small></span><span class="switch"><input type="checkbox" name="phone_verified" value="1"><span></span></span></label>
|
||||
</div>
|
||||
</section>
|
||||
{% if fields %}<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-forms"></i></span><div><b>Дополнительные поля</b><span>Активные поля из конструктора публичного профиля.</span></div></div><div class="form-grid">{% for field in fields %}{% if field.is_active %}<label class="field col-6"><span class="field-label">{{ field.name }}{% if field.is_required %} <span class="text-danger">*</span>{% endif %}</span>{% if field.type=='textarea' %}<textarea class="textarea" name="extra[{{ field.id }}]" rows="3"{{ field.is_required?' required':'' }}></textarea>{% elseif field.type=='select' %}<select class="select" name="extra[{{ field.id }}]"{{ field.is_required?' required':'' }}><option value="">Выберите значение</option>{% for choice in field.choices %}<option value="{{ choice|e('html_attr') }}">{{ choice }}</option>{% endfor %}</select>{% elseif field.type=='checkbox' %}<span class="customers-editor-checkbox"><span class="switch"><input type="checkbox" name="extra[{{ field.id }}]" value="1"><span></span></span><span>Да / нет</span></span>{% else %}<input class="input" type="{{ field.type in ['date','number','email','tel'] ? field.type : 'text' }}" name="extra[{{ field.id }}]"{{ field.is_required?' required':'' }}>{% endif %}<span class="field-hint mono">{{ field.code }}</span></label>{% endif %}{% endfor %}</div></section>{% endif %}
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-key"></i></span><div><b>Безопасность</b><span>Пароль изменится, только если заполнить поле.</span></div></div><label class="field customers-editor-password"><span class="field-label">Новый пароль</span><input class="input" type="password" name="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><span class="field-hint">При смене пароля будут отозваны remember-сессии и ссылки восстановления.</span></label></section>
|
||||
<section class="customers-form-section"><div class="customers-form-section-head"><span class="icon-tile" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-key"></i></span><div><b>Безопасность</b><span>Пароль изменится, только если заполнить поле.</span></div></div><label class="field customers-editor-password"><span class="field-label">Новый пароль</span><span class="input-wrap customers-editor-input"><i class="ti ti-lock"></i><input class="input" type="password" name="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"></span><span class="field-hint">При смене пароля будут отозваны remember-сессии и ссылки восстановления.</span></label></section>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="drawer-footer"><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить</button></footer>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* ── KPI ───────────────────────────────────────────── */
|
||||
.dashboard-kpis {
|
||||
@@ -62,7 +61,6 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dashboard-delta {
|
||||
@@ -75,7 +73,6 @@
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-delta .ti {
|
||||
font-size: 14px;
|
||||
@@ -154,7 +151,6 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-chart-legend small {
|
||||
color: var(--text-secondary);
|
||||
@@ -216,7 +212,6 @@
|
||||
border-top: 1px solid var(--border-default);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-chart-axis .is-weekend {
|
||||
color: var(--text-secondary);
|
||||
@@ -253,7 +248,6 @@
|
||||
.dashboard-status-count {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-status-bar {
|
||||
grid-column: 1 / -1;
|
||||
@@ -350,7 +344,6 @@
|
||||
.dashboard-money {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-status-tag {
|
||||
display: flex;
|
||||
@@ -412,7 +405,6 @@
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dashboard-document-list > a > .ti {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -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,243 @@
|
||||
<?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\Common\SystemTables;
|
||||
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 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 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 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 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 ti-menu-2'
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function users($query, $limit = 6)
|
||||
{
|
||||
$table = SystemTables::table('users');
|
||||
if (!self::available($table)) { return array(); }
|
||||
// У таблицы пользователей смешанные коллации (email обычно ascii,
|
||||
// name/login — utf8mb4), поэтому приводим сравниваемые колонки к
|
||||
// charset соединения (utf8 = utf8mb3, см. dbchar): иначе
|
||||
// кириллический запрос роняет провайдер на «illegal mix of
|
||||
// collations». Ведущий подстановочный знак и так исключает
|
||||
// использование индекса, так что CONVERT ничего не стоит.
|
||||
$rows = DB::query(
|
||||
'SELECT id,name,login,email,is_active FROM ' . $table
|
||||
. ' WHERE CONVERT(name USING utf8) LIKE %ss'
|
||||
. ' OR CONVERT(login USING utf8) LIKE %ss'
|
||||
. ' OR CONVERT(email USING utf8) LIKE %ss OR id=%i'
|
||||
. ' ORDER BY is_active DESC,name LIMIT ' . max(1, min(12, (int) $limit)),
|
||||
$query,
|
||||
$query,
|
||||
$query,
|
||||
(int) $query
|
||||
)->getAll() ?: array();
|
||||
$out = array();
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) $row['name']);
|
||||
$login = trim((string) $row['login']);
|
||||
$email = trim((string) $row['email']);
|
||||
$title = $name !== '' ? $name : ($login !== '' ? '@' . $login : $email);
|
||||
$parts = array('#' . (int) $row['id']);
|
||||
if ($email !== '') { $parts[] = $email; }
|
||||
if (empty($row['is_active'])) { $parts[] = 'выключен'; }
|
||||
$out[] = self::item(
|
||||
'user',
|
||||
'Пользователи',
|
||||
self::decode($title !== '' ? $title : ('#' . (int) $row['id'])),
|
||||
implode(' · ', $parts),
|
||||
'/users/' . (int) $row['id'],
|
||||
'ti ti-user'
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
'users' => array('provider' => array(self::class, 'users'), 'permission' => 'view_users', 'priority' => 60, 'limit' => 6),
|
||||
) 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>
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
'parent_dir' => Model::parentDir($file['path']),
|
||||
'can_manage' => Permission::check('manage_media'),
|
||||
'supports_webp' => Model::supportsWebp(),
|
||||
'presets' => ImagePresets::options(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -88,7 +89,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 +96,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 +238,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(
|
||||
@@ -262,6 +262,7 @@
|
||||
'height' => Request::postInt('height', 240),
|
||||
'format' => Request::postStr('format', 'original'),
|
||||
'quality' => Request::postInt('quality', 82),
|
||||
'webp_twin' => Request::postInt('webp_twin', 0),
|
||||
'crop_enabled' => Request::postStr('crop_enabled', ''),
|
||||
'crop_x' => Request::postInt('crop_x', 0),
|
||||
'crop_y' => Request::postInt('crop_y', 0),
|
||||
@@ -340,6 +341,53 @@
|
||||
));
|
||||
}
|
||||
|
||||
public function presets(array $params = array())
|
||||
{
|
||||
if (!Permission::check('view_media')) {
|
||||
return $this->error('Недостаточно прав', array(), 403);
|
||||
}
|
||||
|
||||
return $this->success('', array('data' => array(
|
||||
'items' => ImagePresets::all(),
|
||||
'modes' => ImagePresets::modes(),
|
||||
'formats' => ImagePresets::formats(),
|
||||
'can_manage' => Permission::check('manage_media'),
|
||||
)));
|
||||
}
|
||||
|
||||
public function storePreset(array $params = array())
|
||||
{
|
||||
if (($err = $this->guard()) !== null) { return $err; }
|
||||
list($errors, $data) = ImagePresets::validate(Request::postAll(), 0);
|
||||
if (!empty($errors)) { return $this->error('Проверьте поля', $errors, 422); }
|
||||
|
||||
$id = ImagePresets::save(0, $data);
|
||||
return $this->success('Вид создан', array('data' => array('id' => $id)));
|
||||
}
|
||||
|
||||
public function updatePreset(array $params = array())
|
||||
{
|
||||
if (($err = $this->guard()) !== null) { return $err; }
|
||||
$id = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
if (!ImagePresets::one($id)) { return $this->error('Вид не найден', array(), 404); }
|
||||
|
||||
list($errors, $data) = ImagePresets::validate(Request::postAll(), $id);
|
||||
if (!empty($errors)) { return $this->error('Проверьте поля', $errors, 422); }
|
||||
|
||||
ImagePresets::save($id, $data);
|
||||
return $this->success('Вид сохранён', array('data' => array('id' => $id)));
|
||||
}
|
||||
|
||||
public function deletePreset(array $params = array())
|
||||
{
|
||||
if (($err = $this->guard()) !== null) { return $err; }
|
||||
if (!ImagePresets::delete(isset($params['id']) ? (int) $params['id'] : 0)) {
|
||||
return $this->error('Вид не найден', array(), 404);
|
||||
}
|
||||
|
||||
return $this->success('Вид удалён');
|
||||
}
|
||||
|
||||
protected function guard()
|
||||
{
|
||||
return $this->guardPermission('manage_media');
|
||||
@@ -352,6 +400,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file adminx/modules/Media/ImagePresets.php
|
||||
| @author AVE.cms <support@ave-cms.ru>
|
||||
| @copyright 2007-2026 (c) AVE.cms
|
||||
| @link https://ave-cms.ru
|
||||
| @version 3.3
|
||||
*/
|
||||
|
||||
namespace App\Adminx\Media;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\DatabaseSchema;
|
||||
use App\Content\ContentTables;
|
||||
use DB;
|
||||
|
||||
/**
|
||||
* Справочник «видов изображений»: именованные наборы параметров обрезки
|
||||
* (режим + размер + формат + качество), которые подставляются в редактор
|
||||
* медиа и привязываются к полям-картинкам рубрик.
|
||||
*/
|
||||
class ImagePresets
|
||||
{
|
||||
const MAX_SIDE = 2400;
|
||||
|
||||
public static function table()
|
||||
{
|
||||
return ContentTables::table('media_image_presets');
|
||||
}
|
||||
|
||||
public static function available()
|
||||
{
|
||||
try { return DatabaseSchema::tableExists(self::table()); }
|
||||
catch (\Throwable $e) { return false; }
|
||||
}
|
||||
|
||||
public static function modes()
|
||||
{
|
||||
return array(
|
||||
'cover' => 'Обрезать под размер',
|
||||
'contain' => 'Вписать целиком',
|
||||
'fit' => 'Уменьшить (без обрезки)',
|
||||
'stretch' => 'Растянуть',
|
||||
);
|
||||
}
|
||||
|
||||
public static function formats()
|
||||
{
|
||||
return array(
|
||||
'original' => 'Как есть',
|
||||
'jpg' => 'JPG',
|
||||
'png' => 'PNG',
|
||||
'webp' => 'WebP',
|
||||
);
|
||||
}
|
||||
|
||||
public static function all()
|
||||
{
|
||||
if (!self::available()) { return array(); }
|
||||
$rows = DB::query(
|
||||
'SELECT * FROM ' . self::table() . ' ORDER BY sort_order ASC, title ASC, id ASC'
|
||||
)->getAll() ?: array();
|
||||
|
||||
return array_map(array(__CLASS__, 'format'), $rows);
|
||||
}
|
||||
|
||||
public static function one($id)
|
||||
{
|
||||
if (!self::available()) { return null; }
|
||||
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc();
|
||||
return $row ? self::format($row) : null;
|
||||
}
|
||||
|
||||
public static function byCode($code)
|
||||
{
|
||||
$code = self::slug($code);
|
||||
if ($code === '' || !self::available()) { return null; }
|
||||
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE code=%s LIMIT 1', $code)->getAssoc();
|
||||
return $row ? self::format($row) : null;
|
||||
}
|
||||
|
||||
public static function count()
|
||||
{
|
||||
if (!self::available()) { return 0; }
|
||||
return (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить и нормализовать вход. Возвращает [errors, data].
|
||||
*/
|
||||
public static function validate(array $input, $id = 0)
|
||||
{
|
||||
$errors = array();
|
||||
$title = trim((string) (isset($input['title']) ? $input['title'] : ''));
|
||||
if ($title === '') { $errors['title'] = 'Укажите название'; }
|
||||
|
||||
$code = self::slug(isset($input['code']) && trim((string) $input['code']) !== '' ? $input['code'] : $title);
|
||||
if ($code === '') { $errors['code'] = 'Не удалось построить код из названия'; }
|
||||
|
||||
$mode = (string) (isset($input['mode']) ? $input['mode'] : 'cover');
|
||||
if (!array_key_exists($mode, self::modes())) { $errors['mode'] = 'Неизвестный режим'; }
|
||||
|
||||
$format = (string) (isset($input['format']) ? $input['format'] : 'original');
|
||||
if (!array_key_exists($format, self::formats())) { $errors['format'] = 'Неизвестный формат'; }
|
||||
|
||||
$width = (int) (isset($input['width']) ? $input['width'] : 0);
|
||||
$height = (int) (isset($input['height']) ? $input['height'] : 0);
|
||||
foreach (array('width' => $width, 'height' => $height) as $key => $value) {
|
||||
if ($value < 0 || $value > self::MAX_SIDE) {
|
||||
$errors[$key] = 'Размер от 0 до ' . self::MAX_SIDE;
|
||||
}
|
||||
}
|
||||
|
||||
// «Вписать/растянуть/обрезать» требуют обе стороны, «уменьшить» — хотя бы одну.
|
||||
if ($mode === 'fit') {
|
||||
if ($width < 1 && $height < 1) { $errors['width'] = 'Для «уменьшить» задайте ширину или высоту'; }
|
||||
} elseif ($width < 1 || $height < 1) {
|
||||
$errors['width'] = 'Задайте ширину и высоту';
|
||||
}
|
||||
|
||||
$quality = (int) (isset($input['quality']) ? $input['quality'] : 82);
|
||||
if ($quality < 1 || $quality > 100) { $errors['quality'] = 'Качество 1–100'; }
|
||||
|
||||
if (empty($errors) && self::codeExists($code, (int) $id)) {
|
||||
$errors['code'] = 'Вид с таким кодом уже есть';
|
||||
}
|
||||
|
||||
return array($errors, array(
|
||||
'code' => $code,
|
||||
'title' => mb_substr($title, 0, 190, 'UTF-8'),
|
||||
'group_label' => mb_substr(trim((string) (isset($input['group_label']) ? $input['group_label'] : '')), 0, 120, 'UTF-8'),
|
||||
'mode' => $mode,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'format' => $format,
|
||||
'quality' => $quality,
|
||||
'webp_twin' => empty($input['webp_twin']) ? 0 : 1,
|
||||
'sort_order' => max(0, min(9999, (int) (isset($input['sort_order']) ? $input['sort_order'] : 100))),
|
||||
));
|
||||
}
|
||||
|
||||
public static function save($id, array $data)
|
||||
{
|
||||
$id = (int) $id;
|
||||
$now = time();
|
||||
if ($id > 0) {
|
||||
$data['updated_at'] = $now;
|
||||
DB::Update(self::table(), $data, 'id=%i', $id);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$data['created_at'] = $now;
|
||||
$data['updated_at'] = $now;
|
||||
DB::Insert(self::table(), $data);
|
||||
return (int) DB::insertId();
|
||||
}
|
||||
|
||||
public static function delete($id)
|
||||
{
|
||||
if (!self::available()) { return false; }
|
||||
$exists = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE id=%i', (int) $id)->getValue();
|
||||
if ($exists < 1) { return false; }
|
||||
DB::Delete(self::table(), 'id=%i', (int) $id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Опции для выпадающих списков (редактор, настройки поля). */
|
||||
public static function options()
|
||||
{
|
||||
$out = array();
|
||||
foreach (self::all() as $preset) {
|
||||
$out[] = array(
|
||||
'code' => $preset['code'],
|
||||
'title' => $preset['title'],
|
||||
'group_label' => $preset['group_label'],
|
||||
'mode' => $preset['mode'],
|
||||
'width' => $preset['width'],
|
||||
'height' => $preset['height'],
|
||||
'format' => $preset['format'],
|
||||
'quality' => $preset['quality'],
|
||||
'webp_twin' => $preset['webp_twin'],
|
||||
'label' => $preset['label'],
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function codeExists($code, $exceptId = 0)
|
||||
{
|
||||
if (!self::available()) { return false; }
|
||||
return (bool) DB::query(
|
||||
'SELECT id FROM ' . self::table() . ' WHERE code=%s AND id!=%i LIMIT 1',
|
||||
self::slug($code),
|
||||
(int) $exceptId
|
||||
)->getValue();
|
||||
}
|
||||
|
||||
protected static function format(array $row)
|
||||
{
|
||||
$modes = self::modes();
|
||||
$width = (int) (isset($row['width']) ? $row['width'] : 0);
|
||||
$height = (int) (isset($row['height']) ? $row['height'] : 0);
|
||||
$mode = (string) (isset($row['mode']) ? $row['mode'] : 'cover');
|
||||
$size = $width && $height ? ($width . '×' . $height) : ($width ? ($width . '×авто') : ($height ? ('авто×' . $height) : 'авто'));
|
||||
return array(
|
||||
'id' => (int) $row['id'],
|
||||
'code' => (string) $row['code'],
|
||||
'title' => (string) $row['title'],
|
||||
'group_label' => (string) (isset($row['group_label']) ? $row['group_label'] : ''),
|
||||
'mode' => $mode,
|
||||
'mode_label' => isset($modes[$mode]) ? $modes[$mode] : $mode,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'format' => (string) (isset($row['format']) ? $row['format'] : 'original'),
|
||||
'quality' => (int) (isset($row['quality']) ? $row['quality'] : 82),
|
||||
'webp_twin' => (int) (isset($row['webp_twin']) ? $row['webp_twin'] : 1),
|
||||
'sort_order' => (int) (isset($row['sort_order']) ? $row['sort_order'] : 100),
|
||||
'label' => $row['title'] . ' · ' . $size,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function slug($value)
|
||||
{
|
||||
$value = mb_strtolower(trim((string) $value), 'UTF-8');
|
||||
$map = array(
|
||||
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e','ж'=>'zh','з'=>'z','и'=>'i','й'=>'y',
|
||||
'к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u','ф'=>'f',
|
||||
'х'=>'h','ц'=>'c','ч'=>'ch','ш'=>'sh','щ'=>'sch','ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu','я'=>'ya',
|
||||
);
|
||||
$value = strtr($value, $map);
|
||||
$value = preg_replace('/[^a-z0-9]+/', '-', $value);
|
||||
return substr(trim((string) $value, '-'), 0, 64);
|
||||
}
|
||||
}
|
||||
+110
-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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,6 +556,15 @@
|
||||
$name = self::uniqueName($outAbsDir, self::safeName($base, false), $r['format']);
|
||||
$out = $outAbsDir . DIRECTORY_SEPARATOR . $name;
|
||||
self::saveImage($dst, $out, $r['format'], $r['quality']);
|
||||
|
||||
// WebP-двойник рядом: если вид просит и результат ещё не webp —
|
||||
// кладём тот же кадр в .webp с тем же базовым именем (foto-...-.webp),
|
||||
// чтобы сайт отдавал webp с jpg-фолбэком. Делаем до imagedestroy.
|
||||
if (!empty($input['webp_twin']) && $r['format'] !== 'webp' && self::supportsWebp()) {
|
||||
$webpName = pathinfo($name, PATHINFO_FILENAME) . '.webp';
|
||||
self::saveImage($dst, $outAbsDir . DIRECTORY_SEPARATOR . $webpName, 'webp', $r['quality']);
|
||||
}
|
||||
|
||||
imagedestroy($dst);
|
||||
return rtrim($outDir, '/') . '/' . $name;
|
||||
}
|
||||
@@ -708,7 +753,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;
|
||||
@@ -730,3 +737,17 @@ button.media-thumb {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.media-presets-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.media-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2, #f6f8fb); }
|
||||
.media-preset-row .media-preset-main { flex: 1; min-width: 0; }
|
||||
.media-preset-row .media-preset-main small { color: var(--text-secondary, #667085); display: block; margin-top: 2px; }
|
||||
.media-presets-form { margin-top: 16px; }
|
||||
|
||||
.media-preset-pick { margin-bottom: 12px; }
|
||||
.media-preset-pick-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.media-preset-pick-field > span { font-size: 13px; color: var(--text-secondary, #667085); }
|
||||
.media-preset-warn { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--amber-100, #fef3c7); color: var(--amber-700, #b45309); font-size: 13px; }
|
||||
|
||||
.media-preset-check { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.media-preset-check > span { color: var(--text-secondary, #667085); font-size: 13px; }
|
||||
|
||||
@@ -62,7 +62,16 @@
|
||||
if (clearThumbs) { self.clearThumbnails(clearThumbs); return; }
|
||||
if (webp) { self.convertWebp(webp); return; }
|
||||
if (zoom) { e.preventDefault(); self.openResultLarge(); return; }
|
||||
if (copy) { self.copy(copy.getAttribute('data-copy')); }
|
||||
if (copy) { self.copy(copy.getAttribute('data-copy')); return; }
|
||||
|
||||
var presetsOpen = e.target.closest('[data-media-presets-open]');
|
||||
if (presetsOpen) { self.loadPresets(); return; }
|
||||
if (e.target.closest('[data-preset-save]')) { self.savePreset(); return; }
|
||||
if (e.target.closest('[data-preset-reset]')) { self.resetPresetForm(); return; }
|
||||
var presetEdit = e.target.closest('[data-preset-edit]');
|
||||
if (presetEdit) { self.editPreset(presetEdit.getAttribute('data-preset-edit')); return; }
|
||||
var presetDelete = e.target.closest('[data-preset-delete]');
|
||||
if (presetDelete) { self.deletePreset(presetDelete.getAttribute('data-preset-delete')); return; }
|
||||
});
|
||||
},
|
||||
|
||||
@@ -70,6 +79,119 @@
|
||||
return Adminx.base();
|
||||
},
|
||||
|
||||
presetData: null,
|
||||
|
||||
loadPresets: function () {
|
||||
var self = this;
|
||||
Adminx.Loader.show();
|
||||
Adminx.Ajax.request(this.base() + '/media/presets').then(function (payload) {
|
||||
Adminx.Loader.hide();
|
||||
var r = payload.data || {};
|
||||
if (!r.success || !r.data) { Adminx.Toast.show(r.message || 'Не удалось загрузить виды', 'error'); return; }
|
||||
self.presetData = r.data;
|
||||
self.fillPresetSelects(r.data.modes || {}, r.data.formats || {});
|
||||
self.renderPresets(r.data.items || []);
|
||||
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
|
||||
},
|
||||
|
||||
fillPresetSelects: function (modes, formats) {
|
||||
var m = document.querySelector('[data-preset-modes]');
|
||||
var f = document.querySelector('[data-preset-formats]');
|
||||
if (m && !m.getAttribute('data-filled')) {
|
||||
m.innerHTML = Object.keys(modes).map(function (k) { return '<option value="' + esc(k) + '">' + esc(modes[k]) + '</option>'; }).join('');
|
||||
m.setAttribute('data-filled', '1');
|
||||
}
|
||||
if (f && !f.getAttribute('data-filled')) {
|
||||
f.innerHTML = Object.keys(formats).map(function (k) { return '<option value="' + esc(k) + '">' + esc(formats[k]) + '</option>'; }).join('');
|
||||
f.setAttribute('data-filled', '1');
|
||||
}
|
||||
},
|
||||
|
||||
renderPresets: function (items) {
|
||||
var list = document.querySelector('[data-preset-list]');
|
||||
if (!list) { return; }
|
||||
if (!items.length) { list.innerHTML = '<div class="empty-state">Виды ещё не заданы. Добавьте первый в форме ниже.</div>'; return; }
|
||||
var canManage = this.presetData && this.presetData.can_manage;
|
||||
list.innerHTML = items.map(function (item) {
|
||||
return '<div class="media-preset-row">'
|
||||
+ '<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-aspect-ratio"></i></span>'
|
||||
+ '<div class="media-preset-main"><div><b>' + esc(item.title) + '</b>'
|
||||
+ (item.group_label ? ' <span class="badge badge-gray">' + esc(item.group_label) + '</span>' : '') + '</div>'
|
||||
+ '<small>' + esc(item.mode_label) + ' · ' + item.width + '×' + item.height + (item.format !== 'original' ? ' · ' + esc(item.format) : '') + ' · q' + item.quality + (item.webp_twin ? ' · +WebP' : '') + '</small></div>'
|
||||
+ (canManage ? '<div class="cluster"><button class="btn btn-ghost btn-icon btn-sm" type="button" data-preset-edit="' + item.id + '" data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>'
|
||||
+ '<button class="btn btn-ghost btn-icon btn-sm media-action-danger" type="button" data-preset-delete="' + item.id + '" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
},
|
||||
|
||||
editPreset: function (id) {
|
||||
if (!this.presetData) { return; }
|
||||
var item = (this.presetData.items || []).filter(function (p) { return String(p.id) === String(id); })[0];
|
||||
var form = document.getElementById('mediaPresetForm');
|
||||
if (!item || !form) { return; }
|
||||
form.querySelector('[name="id"]').value = item.id;
|
||||
form.querySelector('[name="title"]').value = item.title;
|
||||
form.querySelector('[name="group_label"]').value = item.group_label || '';
|
||||
form.querySelector('[name="mode"]').value = item.mode;
|
||||
form.querySelector('[name="format"]').value = item.format;
|
||||
form.querySelector('[name="width"]').value = item.width;
|
||||
form.querySelector('[name="height"]').value = item.height;
|
||||
form.querySelector('[name="quality"]').value = item.quality;
|
||||
var twin = form.querySelector('[name="webp_twin"]');
|
||||
if (twin) { twin.checked = !!item.webp_twin; }
|
||||
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
|
||||
},
|
||||
|
||||
resetPresetForm: function () {
|
||||
var form = document.getElementById('mediaPresetForm');
|
||||
if (!form) { return; }
|
||||
form.reset();
|
||||
form.querySelector('[name="id"]').value = '';
|
||||
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
|
||||
},
|
||||
|
||||
savePreset: function () {
|
||||
var form = document.getElementById('mediaPresetForm');
|
||||
if (!form) { return; }
|
||||
var self = this;
|
||||
var id = (form.querySelector('[name="id"]').value || '').trim();
|
||||
var url = this.base() + '/media/presets' + (id ? '/' + id : '');
|
||||
form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; });
|
||||
Adminx.Loader.show();
|
||||
Adminx.Ajax.post(url, new FormData(form)).then(function (payload) {
|
||||
Adminx.Loader.hide();
|
||||
var r = payload.data || {};
|
||||
if (!r.success) {
|
||||
if (r.errors) { Object.keys(r.errors).forEach(function (k) { var el = form.querySelector('[data-error="' + k + '"]'); if (el) { el.textContent = r.errors[k]; } }); }
|
||||
Adminx.Toast.show(r.message || 'Проверьте поля', 'error');
|
||||
return;
|
||||
}
|
||||
Adminx.Toast.show(r.message || 'Сохранено', 'success');
|
||||
self.resetPresetForm();
|
||||
self.loadPresets();
|
||||
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
|
||||
},
|
||||
|
||||
deletePreset: function (id) {
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'danger',
|
||||
title: 'Удалить вид?',
|
||||
message: 'Он исчезнет из списка подгонки. Уже обрезанные картинки не изменятся.',
|
||||
confirmLabel: 'Удалить',
|
||||
onConfirm: function () {
|
||||
Adminx.Loader.show();
|
||||
Adminx.Ajax.post(self.base() + '/media/presets/' + id + '/delete').then(function (payload) {
|
||||
Adminx.Loader.hide();
|
||||
var r = payload.data || {};
|
||||
if (!r.success) { Adminx.Toast.show(r.message || 'Не удалось удалить', 'error'); return; }
|
||||
Adminx.Toast.show(r.message || 'Удалено', 'success');
|
||||
self.loadPresets();
|
||||
}).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
modalInput: function (cfg, done) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
@@ -165,8 +287,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 () {
|
||||
@@ -547,6 +669,52 @@
|
||||
if (ratioSelect) {
|
||||
ratioSelect.addEventListener('change', function (e) { setRatio(e.target.value); });
|
||||
}
|
||||
|
||||
var presetSelect = form.querySelector('[data-crop-preset]');
|
||||
var presetWarn = form.querySelector('[data-crop-preset-warning]');
|
||||
if (presetSelect) {
|
||||
presetSelect.addEventListener('change', function () {
|
||||
var opt = presetSelect.options[presetSelect.selectedIndex];
|
||||
var webpTwinInput = form.querySelector('[data-webp-twin]');
|
||||
if (!opt || !opt.value) {
|
||||
if (webpTwinInput) { webpTwinInput.value = '0'; }
|
||||
if (presetWarn) { presetWarn.hidden = true; }
|
||||
return;
|
||||
}
|
||||
if (webpTwinInput) { webpTwinInput.value = opt.getAttribute('data-webp-twin') === '1' ? '1' : '0'; }
|
||||
var pw = parseInt(opt.getAttribute('data-width'), 10) || 0;
|
||||
var ph = parseInt(opt.getAttribute('data-height'), 10) || 0;
|
||||
var pmode = opt.getAttribute('data-mode');
|
||||
var pformat = opt.getAttribute('data-format');
|
||||
var pquality = parseInt(opt.getAttribute('data-quality'), 10) || 82;
|
||||
var modeInput = form.querySelector('[name="mode"]');
|
||||
var formatInput = form.querySelector('[name="format"]');
|
||||
var qualityInput = form.querySelector('[name="quality"]');
|
||||
if (modeInput && pmode) { modeInput.value = pmode; }
|
||||
if (formatInput && pformat && formatInput.querySelector('option[value="' + pformat + '"]')) { formatInput.value = pformat; }
|
||||
if (qualityInput) { qualityInput.value = pquality; }
|
||||
// рамку кропа — под соотношение вида (если заданы обе стороны)
|
||||
if (pw > 0 && ph > 0) {
|
||||
if (ratioSelect) { ratioSelect.value = 'free'; }
|
||||
setRatio(pw + ':' + ph);
|
||||
}
|
||||
// выходной размер — точные размеры вида (перекрывает пересчёт из sync)
|
||||
if (inputs.outputW && pw > 0) { inputs.outputW.value = pw; }
|
||||
if (inputs.outputH && ph > 0) { inputs.outputH.value = ph; }
|
||||
if (inputs.outputW) { inputs.outputW.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
// предупреждение о растягивании
|
||||
if (presetWarn) {
|
||||
var nw = img.naturalWidth || 0;
|
||||
var nh = img.naturalHeight || 0;
|
||||
if ((pw && nw && nw < pw) || (ph && nh && nh < ph)) {
|
||||
presetWarn.textContent = 'Исходник ' + nw + '×' + nh + ' меньше вида ' + pw + '×' + ph + ' — картинка растянется и будет мыльной.';
|
||||
presetWarn.hidden = false;
|
||||
} else {
|
||||
presetWarn.hidden = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
form.querySelector('[data-crop-reset]').addEventListener('click', function () {
|
||||
state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 };
|
||||
sync(true);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS `{{prefix}}_media_image_presets` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(64) CHARACTER SET ascii NOT NULL DEFAULT '',
|
||||
`title` VARCHAR(190) NOT NULL DEFAULT '',
|
||||
`group_label` VARCHAR(120) NOT NULL DEFAULT '',
|
||||
`mode` VARCHAR(16) CHARACTER SET ascii NOT NULL DEFAULT 'cover',
|
||||
`width` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`height` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`format` VARCHAR(16) CHARACTER SET ascii NOT NULL DEFAULT 'original',
|
||||
`quality` TINYINT UNSIGNED NOT NULL DEFAULT 82,
|
||||
`webp_twin` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
|
||||
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_code` (`code`),
|
||||
KEY `idx_sort` (`sort_order`)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4;
|
||||
@@ -17,7 +17,7 @@
|
||||
return array(
|
||||
'code' => 'media',
|
||||
'name' => 'Медиа',
|
||||
'version' => '0.1.0',
|
||||
'version' => '0.2.3',
|
||||
|
||||
'permissions' => array(
|
||||
'key' => 'media',
|
||||
@@ -56,8 +56,16 @@
|
||||
),
|
||||
),
|
||||
|
||||
'migrations' => array(
|
||||
array('id' => '001_image_presets', 'file' => 'migrations/001_image_presets.sql'),
|
||||
),
|
||||
|
||||
'routes' => array(
|
||||
array('GET', '/media', array(\App\Adminx\Media\Controller::class, 'index')),
|
||||
array('GET', '/media/presets', array(\App\Adminx\Media\Controller::class, 'presets')),
|
||||
array('POST', '/media/presets', array(\App\Adminx\Media\Controller::class, 'storePreset'), array('permission' => 'manage_media')),
|
||||
array('POST', '/media/presets/{id}', array(\App\Adminx\Media\Controller::class, 'updatePreset'), array('permission' => 'manage_media')),
|
||||
array('POST', '/media/presets/{id}/delete', array(\App\Adminx\Media\Controller::class, 'deletePreset'), array('permission' => 'manage_media')),
|
||||
array('GET', '/media/picker', array(\App\Adminx\Media\Controller::class, 'picker')),
|
||||
array('GET', '/media/folder-files', array(\App\Adminx\Media\Controller::class, 'folderFiles')),
|
||||
array('GET', '/media/file', array(\App\Adminx\Media\Controller::class, 'file')),
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="path" value="{{ file.path }}">
|
||||
<input type="hidden" name="crop_enabled" value="0" data-crop-enabled>
|
||||
<input type="hidden" name="webp_twin" value="0" data-webp-twin>
|
||||
<input type="hidden" name="crop_x" value="0" data-crop-input="x">
|
||||
<input type="hidden" name="crop_y" value="0" data-crop-input="y">
|
||||
<input type="hidden" name="crop_w" value="{{ file.width ?: 1 }}" data-crop-input="w">
|
||||
@@ -89,6 +90,19 @@
|
||||
<button class="media-result-stage" type="button" data-media-result-zoom data-tooltip="Открыть крупно" aria-label="Открыть результат крупно"><img alt="Результат" data-media-result-img><span class="media-result-empty" data-media-result-empty>Меняйте настройки — результат обновится здесь</span></button>
|
||||
</div>
|
||||
|
||||
{% if presets %}
|
||||
<div class="media-preset-pick">
|
||||
<label class="media-preset-pick-field"><span><i class="ti ti-aspect-ratio"></i> Подогнать под вид</span>
|
||||
<select class="select" data-crop-preset>
|
||||
<option value="">— вручную —</option>
|
||||
{% for p in presets %}
|
||||
<option value="{{ p.code }}" data-mode="{{ p.mode }}" data-width="{{ p.width }}" data-height="{{ p.height }}" data-format="{{ p.format }}" data-quality="{{ p.quality }}" data-webp-twin="{{ p.webp_twin }}">{{ p.label }}{% if p.webp_twin %} · +WebP{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="media-preset-warn" data-crop-preset-warning hidden></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="media-form-grid">
|
||||
<label><span>Кадр</span><select class="select" data-crop-ratio><option value="original">Оригинал</option><option value="free">Свободный</option><option value="1:1">1:1</option><option value="4:3">4:3</option><option value="16:9">16:9</option><option value="9:16">9:16</option></select></label>
|
||||
<label><span>Режим</span><select class="select" name="mode"><option value="cover">Обрезать</option><option value="contain">Вписать</option><option value="fit">Уменьшить</option><option value="stretch">Растянуть</option></select></label>
|
||||
|
||||
@@ -15,14 +15,48 @@
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<div class="cluster">
|
||||
<button class="btn btn-secondary" type="button" data-media-presets-open data-open-drawer="mediaPresetsDrawer"><i class="ti ti-aspect-ratio"></i>Виды изображений</button>
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="drawer drawer-right drawer-lg" id="mediaPresetsDrawer" role="dialog" aria-modal="true" aria-labelledby="mediaPresetsTitle" hidden>
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<h3 id="mediaPresetsTitle">Виды изображений</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">
|
||||
<div class="media-presets-list" data-preset-list><div class="empty-state">Загрузка...</div></div>
|
||||
{% if can_manage %}
|
||||
<form id="mediaPresetForm" class="form-grid media-presets-form" style="margin-top:16px">
|
||||
<input type="hidden" name="id" value="">
|
||||
<label class="field col-12"><span class="field-label">Название</span><input class="input" name="title" placeholder="Баннер шапки" required><span class="field-error" data-error="title"></span></label>
|
||||
<label class="field col-12"><span class="field-label">Раздел (необязательно)</span><input class="input" name="group_label" placeholder="Новости, Баннеры, Галерея"></label>
|
||||
<label class="field col-6"><span class="field-label">Режим</span><select class="select" name="mode" data-preset-modes></select><span class="field-error" data-error="mode"></span></label>
|
||||
<label class="field col-6"><span class="field-label">Формат</span><select class="select" name="format" data-preset-formats></select><span class="field-error" data-error="format"></span></label>
|
||||
<label class="field col-4"><span class="field-label">Ширина, px</span><input class="input" type="number" name="width" min="0" max="2400" value="0"><span class="field-error" data-error="width"></span></label>
|
||||
<label class="field col-4"><span class="field-label">Высота, px</span><input class="input" type="number" name="height" min="0" max="2400" value="0"><span class="field-error" data-error="height"></span></label>
|
||||
<label class="field col-4"><span class="field-label">Качество</span><input class="input" type="number" name="quality" min="1" max="100" value="82"><span class="field-error" data-error="quality"></span></label>
|
||||
<label class="field col-12 media-preset-check"><input type="checkbox" name="webp_twin" value="1" checked> <span>Создавать WebP рядом с результатом (webp + исходный формат для fallback)</span></label>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<div class="drawer-footer">
|
||||
<button class="btn btn-ghost" type="button" data-close-drawer><i class="ti ti-x"></i>Закрыть</button>
|
||||
<button class="btn btn-ghost" type="button" data-preset-reset style="margin-left:auto"><i class="ti ti-plus"></i>Новый</button>
|
||||
<button class="btn btn-primary" type="button" data-preset-save><i class="ti ti-check"></i>Сохранить</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<div class="media-summary">
|
||||
<div class="card media-stat">
|
||||
<span class="icon-tile" style="--tile-bg:#e8f3ff;--tile-fg:#2563eb"><i class="ti ti-files"></i></span>
|
||||
@@ -33,7 +67,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 +165,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 +213,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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user