From bedb297e6b74994b37742cce928541875c643985 Mon Sep 17 00:00:00 2001 From: Shine Date: Fri, 3 Jul 2026 11:01:00 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20Dropdown=20=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E9=A1=B9=E5=8C=85=E5=90=AB=E5=88=A4=E6=96=AD=E8=A2=AB=E8=AF=AF?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=B8=BA=20str=5Fstarts=5Fwith?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8 现代化改造(#13)把 renderOption() 中 mb_strpos($v, '') 的「包含」判断机械替换成了 str_starts_with,后者几乎恒为 false, 导致已是完整 链接的菜单项被再包一层 ,产生非法嵌套 HTML, Dropdown::make() 渲染的菜单(含每页条数下拉)显示异常。 改为 str_contains,与 DropdownActions 中的正确转换保持一致。 --- src/Widgets/Dropdown.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Widgets/Dropdown.php b/src/Widgets/Dropdown.php index ecba44d46..f51761998 100644 --- a/src/Widgets/Dropdown.php +++ b/src/Widgets/Dropdown.php @@ -219,7 +219,7 @@ protected function renderOption($k, $v) $v = $builder->call($this, $v, $k); } - $v = str_starts_with($v, '') ? $v : "$v"; + $v = str_contains($v, '') ? $v : "$v"; $v = ""; if ($this->divider) { From 2627b75ef4d5f2bfbeb3ec94d0f6b5de65ca8095 Mon Sep 17 00:00:00 2001 From: Shine Date: Fri, 3 Jul 2026 11:01:08 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20assets=5Fversion=20=E6=9C=AA?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=97=B6=E7=BC=93=E5=AD=98=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=A2=AB=E9=9D=99=E9=BB=98=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/admin.php 已定义 'assets_version' => env('ADMIN_ASSETS_VERSION'), 键存在且为 null 时 config($key, $default) 不会取第二参默认值,导致 withVersionQuery() 判定为空、所有 JS/CSS 的 ?v 缓存破坏参数被静默移除, 浏览器长期使用旧缓存资源,升级后下拉等 JS 组件行为异常。 改为 config('admin.assets_version') ?? Admin::VERSION:env 未设置时 回退框架版本(恢复 #27 之前行为),显式置空字符串才禁用版本参数 (保留 #27 的新能力),并同步修正配置注释。 --- config/admin.php | 5 +++-- src/Console/stubs/config.stub | 5 +++-- src/Layout/Asset.php | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/config/admin.php b/config/admin.php index d0471e3c0..149303263 100755 --- a/config/admin.php +++ b/config/admin.php @@ -112,8 +112,9 @@ |-------------------------------------------------------------------------- | | The version string appended to JS/CSS URLs as a cache-busting query - | parameter (e.g. `?v1.0.0`). Defaults to the framework version. Set to - | null/empty to disable the version query entirely. + | parameter (e.g. `?v1.0.0`). When null (env not set), falls back to the + | framework version. Set to an empty string ('') to disable the version + | query entirely. | */ 'assets_version' => env('ADMIN_ASSETS_VERSION'), diff --git a/src/Console/stubs/config.stub b/src/Console/stubs/config.stub index 974e6706c..8555a60b4 100644 --- a/src/Console/stubs/config.stub +++ b/src/Console/stubs/config.stub @@ -110,8 +110,9 @@ return [ |-------------------------------------------------------------------------- | | The version string appended to JS/CSS URLs as a cache-busting query - | parameter (e.g. `?v1.0.0`). Defaults to the framework version. Set to - | null/empty to disable the version query entirely. + | parameter (e.g. `?v1.0.0`). When null (env not set), falls back to the + | framework version. Set to an empty string ('') to disable the version + | query entirely. | */ 'assets_version' => env('ADMIN_ASSETS_VERSION'), diff --git a/src/Layout/Asset.php b/src/Layout/Asset.php index fa24e97e5..2690467f7 100644 --- a/src/Layout/Asset.php +++ b/src/Layout/Asset.php @@ -689,9 +689,10 @@ public function withVersionQuery($url) $url .= '?'; } - $assetsVersion = config('admin.assets_version', Admin::VERSION); + // 键存在但为 null(env 未设置)时回退框架版本;显式置空才禁用版本参数 + $assetsVersion = config('admin.assets_version') ?? Admin::VERSION; - if (! $assetsVersion) { + if ($assetsVersion === '' || $assetsVersion === false) { return rtrim($url, '?'); } From 6fa2c2342f60e2e0b3e5d6df4f276484f38b2427 Mon Sep 17 00:00:00 2001 From: Shine Date: Fri, 3 Jul 2026 11:01:17 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20multipleselect=20=E8=AF=AF=E6=91=98?= =?UTF-8?q?=20select2=20=E5=86=85=E9=83=A8=20change=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit af0d4db2 引入的 .off('change.select2') 会连带移除 select2 自身以同 命名空间绑定在原生 select 上的内部处理器(负责 selection:update 重绘已选项)。脚本被二次求值且 select2 已初始化时(LazyRenderable 重载、校验失败回显、弹窗内表单重渲染),该处理器被永久摘除且因 initialized 守卫不会重建,表现为选中/取消后不再回显。 改用自有命名空间 change.dcat-msel,保留幂等去重意图;select2 选择 变化最终会触发原生 change 事件,隐藏 input 按需禁用的功能不变。 --- resources/views/form/multipleselect.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/form/multipleselect.blade.php b/resources/views/form/multipleselect.blade.php index c6ed2e195..e75b413c9 100755 --- a/resources/views/form/multipleselect.blade.php +++ b/resources/views/form/multipleselect.blade.php @@ -23,7 +23,7 @@ @include('admin::form.select-script') From 1b177af02804ff8e9a39a75611405a9bfda007b2 Mon Sep 17 00:00:00 2001 From: Shine Date: Fri, 3 Jul 2026 11:04:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20SessionMessage=20=E5=90=91=E5=90=8E?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=97=A7=E5=BC=8F=20MessageBag=20flash=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionMessage 迁移(#25)后,alerts/toastr 模板统一经 tryFrom() 读取 flash 值,外部代码沿用旧契约直接 flash 的 MessageBag 会返回 null 而被静默吞掉(旧模板可正常渲染)。 tryFrom() 增加兼容分支:MessageBag 实例(php 序列化会话)与其 序列化数组形态 ['title' => ['x'], 'message' => ['y']](Laravel 13 或 json 序列化会话)均可还原为 SessionMessage;带类型标识数组的 防篡改守卫保持不变。补充 4 个单测覆盖新路径。 --- src/Support/SessionMessage.php | 47 ++++++++++++++++++++++++---- tests/Unit/SessionMessageTest.php | 52 ++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/Support/SessionMessage.php b/src/Support/SessionMessage.php index e6a3f29e0..e972e7257 100644 --- a/src/Support/SessionMessage.php +++ b/src/Support/SessionMessage.php @@ -2,12 +2,17 @@ namespace Dcat\Admin\Support; +use Illuminate\Contracts\Support\MessageBag; + /** * Session 消息对象(兼容 Laravel 10-13,兼容 PHP session.serialization = json/php). * * 解决两类问题: * 1. Laravel 13 中 MessageBag 通过 redirect()->with() 序列化后变为数组的问题 * 2. session.serialization = json 时,protected 属性不会被 json_encode 包含的问题 + * + * 同时向后兼容旧契约:外部代码直接 flash 的 MessageBag(实例或其序列化数组) + * 仍可被 tryFrom() 识别,不会被静默丢弃。 */ final class SessionMessage implements \JsonSerializable { @@ -67,9 +72,11 @@ public function jsonSerialize(): mixed /** * 从 session 中读取的值尝试构造实例. * - * 兼容两种情况: + * 兼容以下情况: * - PHP 序列化(session.serialization = php):值已经是 SessionMessage 对象 * - JSON 序列化(session.serialization = json):值是带类型标识的数组 + * - 旧契约:外部代码直接 flash 的 MessageBag 实例,或其序列化后的数组 + * (形如 ['title' => ['x'], 'message' => ['y']]) */ public static function tryFrom(mixed $value): ?self { @@ -77,10 +84,15 @@ public static function tryFrom(mixed $value): ?self return $value; } - if ( - is_array($value) - && ($value[self::JSON_CLASS_KEY] ?? null) === self::JSON_CLASS_VALUE - ) { + if ($value instanceof MessageBag) { + $value = $value->getMessages(); + } + + if (! is_array($value)) { + return null; + } + + if (($value[self::JSON_CLASS_KEY] ?? null) === self::JSON_CLASS_VALUE) { return new self( is_string($value['title'] ?? null) ? $value['title'] : '', is_string($value['message'] ?? null) ? $value['message'] : '', @@ -88,6 +100,29 @@ public static function tryFrom(mixed $value): ?self ); } - return null; + return self::tryFromMessageBagArray($value); + } + + /** + * 尝试按 MessageBag 序列化数组的形态构造实例(title/message 值为字符串数组). + */ + private static function tryFromMessageBagArray(array $value): ?self + { + if ( + (! isset($value['title']) && ! isset($value['message'])) + || ! is_array($value['title'] ?? []) + || ! is_array($value['message'] ?? []) + ) { + return null; + } + + $title = $value['title'][0] ?? ''; + $message = $value['message'][0] ?? ''; + + if (! is_string($title) || ! is_string($message)) { + return null; + } + + return new self($title, $message); } } diff --git a/tests/Unit/SessionMessageTest.php b/tests/Unit/SessionMessageTest.php index 066a04aac..c53549386 100644 --- a/tests/Unit/SessionMessageTest.php +++ b/tests/Unit/SessionMessageTest.php @@ -189,4 +189,54 @@ public function test_try_from_ignores_non_array_options(): void $this->assertSame('hello', $restored->getMessage()); $this->assertSame([], $restored->getOptions()); } -} \ No newline at end of file + + // ----------------------------------------------------------------------- + // tryFrom — 旧契约 MessageBag 兼容 + // ----------------------------------------------------------------------- + + public function test_try_from_accepts_legacy_message_bag_instance(): void + { + $bag = new \Illuminate\Support\MessageBag([ + 'title' => '操作成功', + 'message' => '记录已保存', + ]); + + $restored = SessionMessage::tryFrom($bag); + + $this->assertNotNull($restored); + $this->assertSame('操作成功', $restored->getTitle()); + $this->assertSame('记录已保存', $restored->getMessage()); + } + + public function test_try_from_accepts_legacy_message_bag_serialized_array(): void + { + $bag = new \Illuminate\Support\MessageBag([ + 'title' => '操作成功', + 'message' => '记录已保存', + ]); + $array = json_decode(json_encode($bag), true); + + $restored = SessionMessage::tryFrom($array); + + $this->assertNotNull($restored); + $this->assertSame('操作成功', $restored->getTitle()); + $this->assertSame('记录已保存', $restored->getMessage()); + } + + public function test_try_from_accepts_message_bag_with_only_title(): void + { + $restored = SessionMessage::tryFrom(['title' => ['仅标题']]); + + $this->assertNotNull($restored); + $this->assertSame('仅标题', $restored->getTitle()); + $this->assertSame('', $restored->getMessage()); + } + + public function test_try_from_rejects_message_bag_shaped_array_with_non_string_values(): void + { + $this->assertNull(SessionMessage::tryFrom([ + 'title' => [['nested']], + 'message' => ['hi'], + ])); + } +} From 011e426ad5e46cb750a8d3be7f544d9204dc6217 Mon Sep 17 00:00:00 2001 From: Shine Date: Fri, 3 Jul 2026 11:05:02 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20HasModelOptions=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8C=89=E6=A8=A1=E5=9E=8B=E4=B8=BB=E9=94=AE=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E7=A1=AC=E7=BC=96=E7=A0=81=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重复代码提取(#14)将 Grid Filter 的 find()(走真实主键)与 Form 的 whereIn 统一为 whereIn($idField),但 idField 默认值硬编码为 'id',非 id 主键的模型在筛选/表单回显时会查错列。 改为 idField 未传时取 (new $model)->getKeyName(),恢复重构前 Grid 分支的正确语义;显式传参的调用方不受影响。同时补上 in_array 严格比较第三参(编码规范要求)。 --- src/Traits/HasModelOptions.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Traits/HasModelOptions.php b/src/Traits/HasModelOptions.php index 95959b948..c7f297f4e 100644 --- a/src/Traits/HasModelOptions.php +++ b/src/Traits/HasModelOptions.php @@ -12,18 +12,20 @@ trait HasModelOptions * Load options from current selected resource(s). * * @param string $model - * @param string $idField + * @param string|null $idField 默认取模型主键 * @param string $textField * @return $this */ - public function model($model, string $idField = 'id', string $textField = 'name') + public function model($model, ?string $idField = null, string $textField = 'name') { if (! class_exists($model) - || ! in_array(Model::class, class_parents($model)) + || ! in_array(Model::class, class_parents($model), true) ) { throw new RuntimeException("[$model] must be a valid model class"); } + $idField ??= (new $model())->getKeyName(); + $this->options = function ($value) use ($model, $idField, $textField) { if (empty($value)) { return [];