ソースを参照

跑马灯改为数组

doge 1 日 前
親
コミット
e85410b35b

+ 29 - 9
app/Services/SiteConfigurationService.php

@@ -18,13 +18,16 @@ class SiteConfigurationService
         $data = Validator::make($input, [
             'translations' => ['present', 'array:zh,en,vi'],
             'translations.*' => ['array:content'],
-            'translations.*.content' => ['nullable', 'string', 'max:2000'],
+            'translations.*.content' => ['array', 'max:100'],
+            'translations.*.content.*' => ['nullable', 'string', 'max:2000'],
             'domains' => ['present', 'array', 'max:100'],
             'domains.*' => ['required', 'string', 'max:2048'],
         ], [
             'translations.present' => '请提交三语言跑马灯内容', 'translations.array' => '跑马灯仅支持zh/en/vi三种语言',
-            'translations.*.array' => '每种语言请提交content对象', 'translations.*.content.string' => '跑马灯内容必须为文本',
-            'translations.*.content.max' => '每种语言的跑马灯最多2000个字符',
+            'translations.*.array' => '每种语言请提交content对象', 'translations.*.content.array' => '跑马灯内容必须为数组',
+            'translations.*.content.max' => '每种语言的跑马灯最多100条',
+            'translations.*.content.*.string' => '每条跑马灯内容必须为文本',
+            'translations.*.content.*.max' => '每条跑马灯最多2000个字符',
             'domains.present' => '请提交域名数组', 'domains.array' => '域名必须为数组', 'domains.max' => '域名最多100个',
             'domains.*.required' => '域名不能为空', 'domains.*.string' => '每个域名必须为字符串', 'domains.*.max' => '每个域名最多2048个字符',
         ])->validate();
@@ -37,7 +40,13 @@ class SiteConfigurationService
             if (!in_array($url, $domains, true)) $domains[] = $url;
         }
         $translations = [];
-        foreach (['zh', 'en', 'vi'] as $language) $translations[$language] = ['content' => trim((string) ($data['translations'][$language]['content'] ?? ''))];
+        foreach (['zh', 'en', 'vi'] as $language) {
+            $content = $data['translations'][$language]['content'] ?? [];
+            if (!is_array($content) || array_values($content) !== $content) {
+                throw ValidationException::withMessages(['translations.' . $language . '.content' => '跑马灯内容必须为顺序数组']);
+            }
+            $translations[$language] = ['content' => array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $content), static fn ($item) => $item !== ''))];
+        }
         $payload = ['translations' => $translations, 'domains' => $domains];
         return DB::transaction(function () use ($payload) {
             DB::table('site_settings')->insertOrIgnore(['id' => 1, 'domains' => '[]', 'created_at' => now(), 'updated_at' => now()]);
@@ -47,8 +56,9 @@ class SiteConfigurationService
                     'domains' => json_encode($payload['domains'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'updated_at' => now()]);
                 DB::table('site_setting_translations')->where('site_setting_id', 1)->delete();
                 foreach ($payload['translations'] as $language => $text) {
-                    if ($text['content'] !== '') DB::table('site_setting_translations')->insert([
-                        'site_setting_id' => 1, 'language' => $language, 'content' => $text['content']]);
+                    if ($text['content'] !== []) DB::table('site_setting_translations')->insert([
+                        'site_setting_id' => 1, 'language' => $language, 'content' => $text['content'][0],
+                        'contents' => json_encode($text['content'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
                 }
                 OperationAuditService::record('site_configuration', 1, 'update', $before, $payload);
             }
@@ -62,13 +72,23 @@ class SiteConfigurationService
         if (!is_array($domains) || array_values($domains) !== $domains || count(array_filter($domains, 'is_string')) !== count($domains)) {
             throw new \RuntimeException('站点域名配置格式异常');
         }
-        $translations = ['zh' => ['content' => ''], 'en' => ['content' => ''], 'vi' => ['content' => '']];
-        if ($row) foreach (DB::table('site_setting_translations')->where('site_setting_id', 1)->get(['language', 'content']) as $text) {
-            if (isset($translations[$text->language])) $translations[$text->language] = ['content' => (string) $text->content];
+        $translations = ['zh' => ['content' => []], 'en' => ['content' => []], 'vi' => ['content' => []]];
+        if ($row) foreach (DB::table('site_setting_translations')->where('site_setting_id', 1)->get(['language', 'content', 'contents']) as $text) {
+            if (isset($translations[$text->language])) $translations[$text->language] = ['content' => $this->contents($text->contents, (string) $text->content)];
         }
         return ['translations' => $translations, 'domains' => $domains];
     }
 
+    private function contents(?string $value, string $legacy): array
+    {
+        if ($value === null) return trim($legacy) === '' ? [] : [trim($legacy)];
+        $items = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
+        if (!is_array($items) || array_values($items) !== $items || count(array_filter($items, 'is_string')) !== count($items)) {
+            throw new \RuntimeException('跑马灯配置格式异常');
+        }
+        return array_values(array_filter(array_map('trim', $items), static fn ($item) => $item !== ''));
+    }
+
     private function domain(string $value, int $index): string
     {
         $url = trim($value);

+ 21 - 0
database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php

@@ -0,0 +1,21 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('site_setting_translations', function (Blueprint $table) {
+            $table->longText('contents')->nullable();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('site_setting_translations', function (Blueprint $table) {
+            $table->dropColumn('contents');
+        });
+    }
+};

+ 57 - 0
docs/总后台/跑马灯多条内容-后台接入变更.md

@@ -0,0 +1,57 @@
+# 跑马灯多条内容:后台接入变更
+
+跑马灯每种语言的 `content` 从字符串改为 `string[]`,接口路径不变。
+
+| 方法 | 路径 | 用途 |
+|---|---|---|
+| GET | `/admin/site/config` | 读取配置 |
+| POST | `/admin/site/config` | 保存配置 |
+
+沿用后台登录请求头;POST 使用 `Content-Type: application/json`。
+
+| 字段 | 新类型 | 规则 |
+|---|---|---|
+| `translations.zh.content` | string[] | 中文多条内容 |
+| `translations.en.content` | string[] | 英文多条内容 |
+| `translations.vi.content` | string[] | 越南文多条内容 |
+| `domains` | string[] | 沿用原域名列表,保存时一并提交 |
+
+每种语言最多100条,每条最多2000个字符。内容按纯文本编辑,按数组顺序展示。保存时去除每条首尾空白,忽略空白条目。
+
+保存示例:
+
+```json
+{
+  "translations": {
+    "zh": {"content": ["欢迎体验", "最新活动请查看活动页面"]},
+    "en": {"content": ["Welcome", "Visit the promotions page"]},
+    "vi": {"content": ["Chào mừng", "Xem trang khuyến mãi"]}
+  },
+  "domains": ["https://www.example.com"]
+}
+```
+
+读取和保存成功均返回:
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "translations": {
+      "zh": {"content": ["欢迎体验", "最新活动请查看活动页面"]},
+      "en": {"content": ["Welcome", "Visit the promotions page"]},
+      "vi": {"content": ["Chào mừng", "Xem trang khuyến mãi"]}
+    },
+    "domains": ["https://www.example.com"]
+  }
+}
+```
+
+页面需要调整:
+
+- 每种语言的单个文本框改为内容列表,支持新增、删除和调整顺序。
+- 读取后直接用 `content` 数组回填;原有单条内容会返回为只有一项的数组。
+- 保存时提交完整三语言和域名列表,用返回的 `data` 回填表单。
+- 清空某语言提交 `"content": []`;未提交的语言也会清空。不要再提交字符串或 `null`。
+- `code=0` 表示成功;失败时展示 `msg`,登录与权限处理沿用现有方式。

+ 83 - 11
tests/Integration/SiteConfigurationTest.php

@@ -43,12 +43,13 @@ class SiteConfigurationTest extends TestCase
         DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY,title TEXT)');
         DB::table('menus')->insert(['id' => 1, 'title' => '前端已有菜单']);
         (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up();
         $this->service = new SiteConfigurationService();
     }
 
     protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
 
-    private function emptyConfig(): array { return ['translations' => ['zh' => ['content' => ''], 'en' => ['content' => ''], 'vi' => ['content' => '']], 'domains' => []]; }
+    private function emptyConfig(): array { return ['translations' => ['zh' => ['content' => []], 'en' => ['content' => []], 'vi' => ['content' => []]], 'domains' => []]; }
 
     public function test_defaults_and_migration_do_not_require_a_separate_configuration_record_or_touch_menus(): void
     {
@@ -58,13 +59,14 @@ class SiteConfigurationTest extends TestCase
         $migration->down();
         $this->assertSame('前端已有菜单', DB::table('menus')->value('title'));
         $migration->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up();
         $this->assertSame($this->emptyConfig(), $this->service->get());
     }
 
     public function test_both_fields_save_together_preserve_domain_order_and_return_normalized_values(): void
     {
-        $result = $this->service->save(['translations' => ['zh' => ['content' => ' 欢迎体验 '], 'en' => ['content' => ' Welcome '], 'vi' => ['content' => ' Chào mừng ']], 'domains' => ['example.com', 'https://EXAMPLE.com/', 'HTTP://BACKUP.EXAMPLE:8080/']]);
-        $this->assertSame(['translations' => ['zh' => ['content' => '欢迎体验'], 'en' => ['content' => 'Welcome'], 'vi' => ['content' => 'Chào mừng']], 'domains' => ['https://example.com', 'http://backup.example:8080']], $result);
+        $result = $this->service->save(['translations' => ['zh' => ['content' => [' 欢迎体验 ', ' 第二条 ', ' ', '', '欢迎体验']], 'en' => ['content' => [' Welcome ', 'Second item']], 'vi' => ['content' => [' Chào mừng ']]], 'domains' => ['example.com', 'https://EXAMPLE.com/', 'HTTP://BACKUP.EXAMPLE:8080/']]);
+        $this->assertSame(['translations' => ['zh' => ['content' => ['欢迎体验', '第二条', '欢迎体验']], 'en' => ['content' => ['Welcome', 'Second item']], 'vi' => ['content' => ['Chào mừng']]], 'domains' => ['https://example.com', 'http://backup.example:8080']], $result);
         $this->assertSame($result, $this->service->get());
         $this->assertSame(1, DB::table('operation_audits')->count());
         $this->service->save($result);
@@ -83,9 +85,17 @@ class SiteConfigurationTest extends TestCase
 
     public static function invalidPayloads(): array
     {
-        $base = ['translations' => ['zh' => ['content' => '测试']], 'domains' => []];
+        $base = ['translations' => ['zh' => ['content' => ['测试']]], 'domains' => []];
         return [[['domains' => []]], [['translations' => []]],
-            [array_replace($base, ['translations' => ['zh' => ['content' => str_repeat('字', 2001)]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => [str_repeat('字', 2001)]]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => '旧字符串']]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => '']]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => null]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => [123]]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => [false]]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => [['nested']]]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => ['first' => '文字']]]])],
+            [array_replace($base, ['translations' => ['zh' => ['content' => array_fill(0, 101, '文字')]]])],
             [array_replace($base, ['domains' => ['primary' => 'https://example.com']])],
             [array_replace($base, ['domains' => array_fill(0, 101, 'https://example.com')])],
             [array_replace($base, ['domains' => ['']])], [array_replace($base, ['domains' => [123]])],
@@ -95,29 +105,91 @@ class SiteConfigurationTest extends TestCase
             [array_replace($base, ['domains' => ['https://example.com/path']])],
             [array_replace($base, ['domains' => ['https://example.com?x=1']])],
             [array_replace($base, ['domains' => ['https://example.com/#fragment']])],
-            [array_replace($base, ['translations' => ['fr' => ['content' => 'French']]])],
+            [array_replace($base, ['translations' => ['fr' => ['content' => ['French']]]])],
             [array_replace($base, ['translations' => ['zh' => '不是对象']])],
             [array_replace($base, ['translations' => ['zh' => ['title' => '未知字段']]])]];
     }
 
     public function test_translations_are_independent_and_full_replacement_clears_omitted_languages(): void
     {
-        $this->service->save(['translations' => ['zh' => ['content' => '中文'], 'en' => ['content' => 'English'], 'vi' => ['content' => 'Tiếng Việt']], 'domains' => ['example.com']]);
+        $this->service->save(['translations' => ['zh' => ['content' => ['中文']], 'en' => ['content' => ['English']], 'vi' => ['content' => ['Tiếng Việt']]], 'domains' => ['example.com']]);
         $this->assertSame(3, DB::table('site_setting_translations')->count());
-        $next = $this->service->save(['translations' => ['zh' => ['content' => '新中文']], 'domains' => ['shared.example']]);
-        $this->assertSame(['zh' => ['content' => '新中文'], 'en' => ['content' => ''], 'vi' => ['content' => '']], $next['translations']);
+        $next = $this->service->save(['translations' => ['zh' => ['content' => ['新中文', '新增一条']]], 'domains' => ['shared.example']]);
+        $this->assertSame(['zh' => ['content' => ['新中文', '新增一条']], 'en' => ['content' => []], 'vi' => ['content' => []]], $next['translations']);
         $this->assertSame(['https://shared.example'], $next['domains']);
         $this->assertSame(1, DB::table('site_setting_translations')->count());
     }
 
     public function test_audit_failure_rolls_back_both_configuration_fields(): void
     {
-        $before = $this->service->save(['translations' => ['zh' => ['content' => '原内容']], 'domains' => ['example.com']]);
+        $before = $this->service->save(['translations' => ['zh' => ['content' => ['原内容', '原第二条']]], 'domains' => ['example.com']]);
         DB::statement('DROP TABLE bot_operation_audits');
-        try { $this->service->save(['translations' => ['zh' => ['content' => '新内容']], 'domains' => ['new.example']]); $this->fail('Expected audit failure'); }
+        try { $this->service->save(['translations' => ['zh' => ['content' => ['新内容', '新第二条']]], 'domains' => ['new.example']]); $this->fail('Expected audit failure'); }
         catch (\Illuminate\Database\QueryException $e) { $this->assertSame($before, $this->service->get()); }
     }
 
+    public function test_existing_single_text_survives_migration_and_becomes_one_array_item(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php';
+        $migration->down();
+        DB::table('site_setting_translations')->insert([
+            ['site_setting_id' => 1, 'language' => 'zh', 'content' => '["原有内容"]'],
+            ['site_setting_id' => 1, 'language' => 'en', 'content' => ''],
+        ]);
+        $migration->up();
+        $config = $this->service->get();
+        $this->assertSame(['["原有内容"]'], $config['translations']['zh']['content']);
+        $this->assertSame([], $config['translations']['en']['content']);
+        $config['translations']['zh']['content'][] = '新增一条';
+        $this->assertSame($config, $this->service->save($config));
+        $this->assertSame($config, $this->service->get());
+        $stored = DB::table('site_setting_translations')->where('language', 'zh')->first();
+        $this->assertSame('["原有内容"]', $stored->content);
+        $this->assertSame($config['translations']['zh']['content'], json_decode($stored->contents, true));
+        $migration->down();
+        $this->assertSame('["原有内容"]', DB::table('site_setting_translations')->where('language', 'zh')->value('content'));
+    }
+
+    public function test_maximum_list_round_trips_and_an_explicit_empty_list_clears_one_language(): void
+    {
+        $items = array_fill(0, 100, str_repeat('字', 2000));
+        $result = $this->service->save(['translations' => ['zh' => ['content' => $items], 'en' => ['content' => ['English']]], 'domains' => []]);
+        $this->assertSame($items, $result['translations']['zh']['content']);
+        $this->assertSame($result, $this->service->get());
+        $result['translations']['zh']['content'] = [];
+        $this->assertSame($result, $this->service->save($result));
+        $this->assertSame($result, $this->service->get());
+        $this->assertSame(['English'], $result['translations']['en']['content']);
+    }
+
+    public function test_invalid_list_does_not_change_an_existing_configuration(): void
+    {
+        $before = $this->service->save(['translations' => ['zh' => ['content' => ['第一条', '第二条']]], 'domains' => ['example.com']]);
+        try {
+            $this->service->save(['translations' => ['zh' => ['content' => ['first' => '错误列表']]], 'domains' => ['new.example']]);
+            $this->fail('Expected validation failure');
+        } catch (ValidationException $e) {
+            $this->assertSame($before, $this->service->get());
+            $this->assertSame(1, DB::table('operation_audits')->count());
+        }
+    }
+
+    public function test_admin_save_after_request_normalization_ignores_blank_items(): void
+    {
+        $request = Request::create('/admin/site/config', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
+            'translations' => ['zh' => ['content' => [' 第一条 ', '', ' ', '第二条']]], 'domains' => [],
+        ], JSON_UNESCAPED_UNICODE));
+        $response = (new \App\Http\Middleware\TrimStrings())->handle($request, function ($request) {
+            return (new \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull())->handle($request, function ($request) {
+                $this->app->instance('request', $request);
+                return (new SiteConfiguration())->save($this->service);
+            });
+        });
+        $this->assertSame(0, $response->getData(true)['code']);
+        $this->assertSame(['第一条', '第二条'], $response->getData(true)['data']['translations']['zh']['content']);
+        $this->assertSame(['第一条', '第二条'], $this->service->get()['translations']['zh']['content']);
+    }
+
     public function test_one_admin_path_supports_read_and_write_with_authentication_and_permission_checks(): void
     {
         $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);