diff --git a/plugin/think-plugs-system/src/builder/MenuBuilder.php b/plugin/think-plugs-system/src/builder/MenuBuilder.php index d1e258ab3..0374ba57f 100644 --- a/plugin/think-plugs-system/src/builder/MenuBuilder.php +++ b/plugin/think-plugs-system/src/builder/MenuBuilder.php @@ -152,98 +152,121 @@ SCRIPT); ->define(function ($form) use ($context) { $form->action(strval($context['actionUrl'] ?? '')) ->attrs(['id' => 'MenuForm', 'data-table-id' => 'MenuTable']) - ->class('system-menu-form'); + ->class(['system-menu-form', 'ta-form-nowrap']); $form->html(''); FormModules::section($form, [ 'title' => '层级归属', - 'description' => '先确认上级菜单与菜单名称。分组菜单建议将链接留空或填写 #,业务页面建议直接指向真实页面节点。', + 'description' => '确认上级与名称,分组菜单可用 #。', ], function ($section) use ($context) { - $grid = $section->div()->class('layui-row layui-col-space15'); + $grid = FormModules::grid($section, 2, 'system-menu-form-row ta-form-grid-compact', 8); - $left = $grid->div()->class('layui-col-xs12 layui-col-md6'); - $left->fields(function ($fields) use ($context) { - $fields->select('pid', '上级菜单', 'Parent Menu', true, '请选择上级菜单或顶级菜单。当前仅支持三级菜单结构,叶子菜单不能继续挂载子节点。', self::buildParentOptions(is_array($context['menus'] ?? null) ? $context['menus'] : []), '', ['lay-search' => null]) - ->text('title', '菜单名称', 'Menu Title', true, '请填写菜单名称,建议控制在 4-8 个汉字,便于导航栏展示。', null, [ - 'placeholder' => '请输入菜单名称', - 'required-error' => '菜单名称不能为空!', - ]); + $parent = FormModules::gridColumn($grid, 2, 'system-menu-basic-col'); + $parent->fields(function ($fields) use ($context) { + $fields->select('pid', '上级菜单', 'Parent Menu', true, '选择上级,叶子菜单不可挂载。', self::buildParentOptions(is_array($context['menus'] ?? null) ? $context['menus'] : []), '', ['lay-search' => null]); }); - $right = $grid->div()->class('layui-col-xs12 layui-col-md6'); - FormModules::readonlyField($right, [ - 'title' => '结构约束', - 'subtitle' => 'Structure Rules', - 'value' => '顶级菜单 / 二级分组 / 三级页面', - 'help' => '系统会在保存时校验父子关系,禁止挂到叶子菜单下,也禁止形成循环层级。', - ]); + $title = FormModules::gridColumn($grid, 2, 'system-menu-basic-col'); + $title->fields(function ($fields) { + $fields->text('title', '菜单名称', 'Menu Title', true, '建议 4-8 个汉字。', null, [ + 'placeholder' => '请输入菜单名称', + 'required-error' => '菜单名称不能为空!', + ]); + }); + + FormModules::note($section, '层级最多三级:顶级菜单 / 二级分组 / 三级页面。', 'help-block color-desc system-menu-section-note'); }); FormModules::section($form, [ 'title' => '跳转与权限', - 'description' => '菜单链接建议填写完整页面节点,如 system/user/index。权限节点为空时,系统会优先根据菜单链接解析访问控制节点。', + 'description' => '设置菜单链接、参数与权限节点。', ], function ($section) { - $grid = $section->div()->class('layui-row layui-col-space15'); + $grid = FormModules::grid($section, 2, 'system-menu-form-row ta-form-grid-compact', 8); - $route = $grid->div()->class('layui-col-xs12 layui-col-md6'); + $route = FormModules::gridColumn($grid, 2); $route->fields(function ($fields) { - $fields->text('url', '菜单链接', 'Menu Url', false, '支持系统页面节点、外部地址、插件布局地址或 # 分组节点。保存时会自动标准化链接格式。', null, [ + $fields->text('url', '菜单链接', 'Menu Url', false, '页面节点、外链或 # 分组。', null, [ 'placeholder' => '例如:system/user/index、https://example.com/docs 或 #', - ])->text('params', '链接参数', 'Query Params', false, '设置菜单链接的 GET 访问参数,如 tab=user&mode=list。', null, [ + ]); + }); + + $permission = FormModules::gridColumn($grid, 2); + $permission->fields(function ($fields) { + $fields->text('node', '权限节点', 'Permission Node', false, '留空时按菜单链接推导。', null, [ + 'placeholder' => '例如:system/user/index', + ]); + }); + + $params = FormModules::gridColumn($grid, 2); + $params->fields(function ($fields) { + $fields->text('params', '链接参数', 'Query Params', false, 'GET 参数,如 tab=user。', null, [ 'placeholder' => '请输入链接参数', ]); }); - $permission = $grid->div()->class('layui-col-xs12 layui-col-md6'); - $permission->fields(function ($fields) { - $fields->text('node', '权限节点', 'Permission Node', false, '显式指定注释式 RBAC 节点。若留空,将按菜单链接推导默认访问节点。', null, [ - 'placeholder' => '例如:system/user/index', - ])->select('target', '打开方式', 'Target Window', true, '设置菜单链接的打开方式。', [ + $target = FormModules::gridColumn($grid, 2); + $target->fields(function ($fields) { + $fields->select('target', '打开方式', 'Target Window', true, '选择链接打开方式。', [ '_self' => '当前窗口', '_blank' => '新窗口', ]); }); - $preview = $section->div()->class('layui-row layui-col-space15'); - $previewUrl = $preview->div()->class('layui-col-xs12 layui-col-md6'); + $preview = FormModules::grid($section, 2, 'system-menu-preview-row ta-form-grid-compact', 8); + $previewUrl = FormModules::gridColumn($preview, 2, 'system-menu-preview-col'); FormModules::readonlyField($previewUrl, [ - 'title' => '标准化链接预览', - 'subtitle' => 'Normalized Target', + 'title' => '链接预览', + 'subtitle' => 'Normalized', 'value' => '', 'input_attrs' => [ 'data-menu-url-preview' => null, - 'placeholder' => '根据菜单链接与参数自动生成', + 'placeholder' => '保存前自动预览', ], - 'help' => '预览仅用于提示保存结果,实际写库仍以后端校验为准。', + 'help' => '预览标准化后的链接。', ]); - $previewNode = $preview->div()->class('layui-col-xs12 layui-col-md6'); + $previewNode = FormModules::gridColumn($preview, 2, 'system-menu-preview-col'); FormModules::readonlyField($previewNode, [ - 'title' => '鉴权节点预览', - 'subtitle' => 'Resolved Node', + 'title' => '权限预览', + 'subtitle' => 'Resolved', 'value' => '', 'input_attrs' => [ 'data-menu-node-preview' => null, - 'placeholder' => '未显式设置时按菜单链接推导', + 'placeholder' => '留空时自动推导', ], - 'help' => '权限节点必须来自注释式 RBAC 的有效 @auth 节点。', + 'help' => '预览 RBAC 访问节点。', ]); + }); FormModules::section($form, [ 'title' => '展示策略', - 'description' => '统一维护菜单图标、排序权重与启停状态。图标支持 layui 与 iconfont 字体类名。', + 'description' => '设置图标、排序和启停状态。', ], function ($section) { - $grid = $section->div()->class('layui-row layui-col-space15'); + $grid = FormModules::grid($section, 3, 'system-menu-form-row ta-form-grid-compact', 8); - $fields = $grid->div()->class('layui-col-xs12 layui-col-md6'); - $fields->fields(function ($fields) { - $status = $fields->text('icon', '菜单图标', 'Menu Icon', false, '可手动输入图标类名,或通过右侧图标选择器回填。', null, [ + $iconField = FormModules::gridColumn($grid, 3, 'system-menu-display-col system-menu-icon-col'); + $iconField->fields(function ($fields) { + $icon = $fields->text('icon', '菜单图标', 'Menu Icon', false, '输入类名或点击选择。', null, [ 'placeholder' => '例如:layui-icon layui-icon-set-fill', - ])->text('sort', '排序权重', 'Sort Order', false, '数值越大越靠前。', null, [ + ]); + $icon->inputRightIcon('layui-icon-theme system-menu-icon-field-button', [ + 'data-open-menu-icon' => null, + 'title' => BuilderLang::text('选择菜单图标'), + ]); + }); + + $sortField = FormModules::gridColumn($grid, 3, 'system-menu-display-col system-menu-sort-col'); + $sortField->fields(function ($fields) { + $fields->text('sort', '排序权重', 'Sort Order', false, '数值越大越靠前。', null, [ 'type' => 'number', 'min' => 0, 'placeholder' => '请输入排序权重', - ])->defaultValue(0)->radio('status', '使用状态', 'Status', '', true, [ + ])->defaultValue(0); + }); + + $statusField = FormModules::gridColumn($grid, 3, 'system-menu-display-col system-menu-status-col'); + $statusField->fields(function ($fields) { + $status = $fields->radio('status', '使用状态', 'Status', '', true, [ 'required-error' => '请选择当前菜单的启用状态。', ]); $status->body()->class('system-menu-status-options')->end()->options([ @@ -251,25 +274,7 @@ SCRIPT); 0 => '已禁用', ])->defaultValue('1'); }); - - $picker = $grid->div()->class('layui-col-xs12 layui-col-md6'); - FormModules::pickerField($picker, [ - 'title' => '图标选择器', - 'subtitle' => 'Icon Picker', - 'value' => '点击打开图标选择器', - 'class' => 'block system-menu-icon-picker', - 'control_class' => 'relative', - 'input_class' => 'layui-input pr40', - 'attrs' => ['data-open-menu-icon' => null], - 'input_attrs' => [ - 'data-open-menu-icon' => null, - 'data-menu-icon-picker-text' => null, - 'placeholder' => '点击选择图标并自动回填', - ], - 'icon_attrs' => ['data-open-menu-icon' => null], - 'help' => '选择后会自动同步到“菜单图标”字段,并实时预览当前图标样式。', - ]); - $picker->div()->class('layui-form-mid color-desc system-menu-icon-preview')->html(sprintf( + $section->div()->class('layui-form-mid color-desc system-menu-icon-preview')->html(sprintf( '%s', self::escape(BuilderLang::text('未设置图标')) )); @@ -353,23 +358,24 @@ SCRIPT; private static function renderFormStyle(): string { return <<<'STYLE' -#MenuForm .system-menu-icon-picker .layui-input { - cursor: pointer; - padding-right: 44px !important; +#MenuForm .system-menu-section-note { + margin-top: -2px; + margin-bottom: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } -#MenuForm .system-menu-icon-picker .input-right-icon { - top: 1px; - right: 1px; - width: 34px; - height: 32px; - line-height: 32px; - box-sizing: border-box; - border-left: 1px solid var(--ta-border-color, #dce8e5); - border-radius: 0 3px 3px 0; +#MenuForm .system-menu-preview-row { + margin-top: 2px; +} +#MenuForm .system-menu-preview-col .layui-input { + cursor: default; } #MenuForm .system-menu-icon-preview { + display: block; + width: 100%; min-height: 20px; - margin-top: 8px; + margin-top: 0; padding: 0 2px; line-height: 20px; } @@ -377,6 +383,29 @@ SCRIPT; width: 18px; text-align: center; } +#MenuForm [data-field-name="icon"] .layui-input { + padding-right: 44px !important; +} +#MenuForm [data-field-name="icon"] .system-menu-icon-field-button { + top: 1px; + right: 1px; + width: 34px; + height: 32px; + color: var(--ta-accent, #009688); + display: block; + cursor: pointer; + font-size: 16px; + line-height: 32px !important; + box-sizing: border-box; + text-align: center; + border-left: 1px solid var(--ta-border-color, #dce8e5); + border-radius: 0 6px 6px 0; + background: var(--ta-surface-soft, #f7fbfb) !important; +} +#MenuForm [data-field-name="icon"] .system-menu-icon-field-button:hover { + color: #fff; + background: var(--ta-accent, #009688) !important; +} #MenuForm [data-field-name="status"] .system-menu-status-options { height: auto !important; min-height: 34px !important; @@ -384,13 +413,16 @@ SCRIPT; line-height: 32px !important; align-items: center; box-sizing: border-box; + overflow: hidden; + white-space: nowrap; border-color: var(--ta-border-color, #dce8e5); background: var(--ta-surface-soft, #f7fbfb) !important; } #MenuForm [data-field-name="status"] .system-menu-status-options .layui-form-radio { - margin: 0 18px 0 0; + margin: 0 12px 0 0; padding-right: 0; line-height: 32px; + white-space: nowrap; } #MenuForm [data-field-name="status"] .system-menu-status-options .layui-form-radio > i { margin-right: 5px; @@ -404,6 +436,7 @@ STYLE; return sprintf(<<<'SCRIPT' $.module.use(['jquery.autocompleter'], function () { var menuForm = document.getElementById('MenuForm') || {}; + var $form = $('#MenuForm'); var menuFormNodes = JSON.parse((menuForm.dataset || {}).menuNodes || '[]'); var menuFormAuths = JSON.parse((menuForm.dataset || {}).menuAuths || '[]'); var iconPickerUrl = String((menuForm.dataset || {}).menuIconPickerUrl || ''); @@ -435,57 +468,60 @@ $.module.use(['jquery.autocompleter'], function () { return parts.join('/'); }; var syncIconPreview = function () { - var value = $.trim(String($('[name="icon"]').val() || '')); - var preview = $('[data-menu-icon-preview]').get(0); + var value = $.trim(String($form.find('[name="icon"]').val() || '')); + var preview = $form.find('[data-menu-icon-preview]').get(0); if (preview) { - preview.className = value || 'layui-icon layui-icon-set-fill'; + preview.className = value || 'layui-icon layui-icon-set-fill font-s14 mr-8'; } - $('[data-menu-icon-preview-text]').text(value || menuI18n.iconNotSet); - $('[data-menu-icon-picker-text]').val(value || '').attr('title', value || ''); + $form.find('[data-menu-icon-preview-text]').text(value || menuI18n.iconNotSet); }; var syncTargetPreview = function () { - var url = $.trim(String($('[name="url"]').val() || '')); - var params = $.trim(String($('[name="params"]').val() || '')).replace(/^[?&]+/, ''); + var url = $.trim(String($form.find('[name="url"]').val() || '')); + var params = $.trim(String($form.find('[name="params"]').val() || '')).replace(/^[?&]+/, ''); var target = normalizeTarget(url); if (target !== '#' && params) { target += (/^(https?:\/\/|\/\/)/i.test(target) && target.indexOf('?') >= 0 ? '&' : '?') + params; } - $('[data-menu-url-preview]').val(target || '#'); + $form.find('[data-menu-url-preview]').val(target || '#'); - var node = normalizeNode($('[name="node"]').val()); + var node = normalizeNode($form.find('[name="node"]').val()); if (!node && target !== '#' && !/^(https?:\/\/|\/\/|@|\[)/i.test(target)) { node = target.split('?')[0].split('/').slice(0, 3).join('/'); } - $('[data-menu-node-preview]').val(node || menuI18n.nodeNotExplicitlySet); + $form.find('[data-menu-node-preview]').val(node || menuI18n.nodeNotExplicitlySet); }; $('body').off('click', '[data-open-menu-icon]').on('click', '[data-open-menu-icon]', function () { if (!iconPickerUrl) return false; - $.form.modal(iconPickerUrl + (iconPickerUrl.indexOf('?') >= 0 ? '&' : '?') + 'field=' + encodeURIComponent('icon'), {}, menuI18n.chooseMenuIcon, undefined, undefined, undefined, '900px'); + $.form.iframe(iconPickerUrl + (iconPickerUrl.indexOf('?') >= 0 ? '&' : '?') + 'field=' + encodeURIComponent('icon'), menuI18n.chooseMenuIcon, ['900px', '700px']); return false; }); - $('[name="icon"]').on('change input', syncIconPreview); - $('[name="url"], [name="params"], [name="node"]').on('change input', syncTargetPreview); - $('input[name=url]').autocompleter({ + $form.find('input[name=url]').autocompleter({ limit: 6, + customClass: ['ta-form-autocompleter'], highlightMatches: true, template: '{{ label }} {{ title }} ', callback: function (node) { - if (!$('input[name=node]').val()) $('input[name=node]').val(node).trigger('change'); + if (!$form.find('input[name=node]').val()) $form.find('input[name=node]').val(node).trigger('change'); + syncTargetPreview(); }, source: (function (subjects, data) { for (var i in subjects) data.push({value: subjects[i].node, label: subjects[i].node, title: subjects[i].title}); return data; })(menuFormNodes, []) }); - $('input[name=node]').autocompleter({ + $form.find('input[name=node]').autocompleter({ limit: 5, + customClass: ['ta-form-autocompleter'], highlightMatches: true, template: '{{ label }} {{ title }} ', + callback: syncTargetPreview, source: (function (subjects, data) { for (var i in subjects) data.push({value: subjects[i].node, label: subjects[i].node, title: subjects[i].title}); return data; })(menuFormAuths, []) }); + $form.find('[name="icon"]').on('change input', syncIconPreview); + $form.find('[name="url"], [name="params"], [name="node"]').on('change input', syncTargetPreview); syncIconPreview(); syncTargetPreview(); }); diff --git a/plugin/think-plugs-system/tests/MenuControllerTest.php b/plugin/think-plugs-system/tests/MenuControllerTest.php index 7a076d3cc..14e6b7122 100644 --- a/plugin/think-plugs-system/tests/MenuControllerTest.php +++ b/plugin/think-plugs-system/tests/MenuControllerTest.php @@ -159,10 +159,38 @@ class MenuControllerTest extends SqliteIntegrationTestCase $this->assertStringContainsString('form-builder-schema', $html); $this->assertStringContainsString('name="title"', $html); + $this->assertStringContainsString('ta-form-nowrap', $html); + $this->assertStringContainsString('data-form-builder-standard-style', $html); + $this->assertStringContainsString('system-menu-form-row', $html); + $this->assertStringContainsString('layui-col-space8', $html); + $this->assertStringContainsString('ta-form-grid-compact', $html); + $this->assertStringContainsString('ta-form-grid-2', $html); + $this->assertStringContainsString('ta-form-grid-3', $html); + $this->assertStringContainsString('layui-col-xs6 ta-form-grid-col', $html); + $this->assertStringContainsString('layui-col-xs4 ta-form-grid-col', $html); + $this->assertStringContainsString('width:50%!important', $html); + $this->assertStringContainsString('width:33.333333%!important', $html); + $this->assertStringContainsString('margin-bottom:6px', $html); + $this->assertStringContainsString('system-menu-basic-col', $html); + $this->assertStringContainsString('system-menu-display-col', $html); $this->assertStringContainsString('data-menu-nodes=', $html); $this->assertStringContainsString('data-menu-auths=', $html); $this->assertStringContainsString('data-open-menu-icon', $html); - $this->assertStringContainsString('标准化链接预览', $html); + $this->assertStringContainsString('ta-form-autocompleter', $html); + $this->assertStringContainsString('top:58px', $html); + $this->assertStringContainsString('层级最多三级', $html); + $this->assertStringContainsString('链接预览', $html); + $this->assertStringContainsString('权限预览', $html); + $this->assertStringContainsString('data-menu-url-preview', $html); + $this->assertStringContainsString('data-menu-node-preview', $html); + $this->assertStringContainsString('data-menu-icon-preview', $html); + $this->assertStringContainsString('选择上级,叶子菜单不可挂载。', $html); + $this->assertStringContainsString('GET 参数,如 tab=user。', $html); + $this->assertStringNotContainsString('当前仅支持三级菜单结构', $html); + $this->assertStringNotContainsString('保存时会自动标准化链接格式', $html); + $this->assertStringNotContainsString('标准化链接预览', $html); + $this->assertStringNotContainsString('鉴权节点预览', $html); + $this->assertStringNotContainsString('图标选择器', $html); $this->assertStringContainsString('name="target"', $html); } @@ -172,10 +200,10 @@ class MenuControllerTest extends SqliteIntegrationTestCase $html = $this->callActionHtml('add', ['pid' => 0]); - $this->assertStringContainsString('Icon Picker', $html); - $this->assertStringContainsString('Icon not set', $html); $this->assertStringContainsString('Choose Menu Icon', $html); - $this->assertStringNotContainsString('未设置图标', $html); + $this->assertStringContainsString('Icon not set', $html); + $this->assertStringNotContainsString('Icon Picker', $html); + $this->assertStringNotContainsString('选择菜单图标', $html); } public function testAddAndEditPersistMenuFields(): void