Przeglądaj źródła

副标题不要

doge 10 godzin temu
rodzic
commit
6c0e7ca329

+ 1 - 1
app/Models/PromotionActivity.php

@@ -9,7 +9,7 @@ class PromotionActivity extends BaseModel
 {
     protected $table = 'promotion_activities';
     protected $guarded = ['id', 'rewarded_count', 'budget_used'];
-    protected $fillable = ['title', 'sub_title', 'translations', 'type', 'status', 'start_time', 'end_time', 'lang', 'sort', 'params', 'content', 'image',
+    protected $fillable = ['title', 'translations', 'type', 'status', 'start_time', 'end_time', 'lang', 'sort', 'params', 'content', 'image',
         'publish_platforms', 'register_time_limited', 'register_start_time', 'register_end_time', 'register_platforms',
         'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'reward_quota', 'budget'];
     protected $casts = ['translations' => 'array', 'publish_platforms' => 'array', 'register_platforms' => 'array', 'register_time_limited' => 'boolean',

+ 10 - 4
app/Services/PromotionAdminService.php

@@ -43,20 +43,26 @@ class PromotionAdminService
         $data = array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
             'start_time' => date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => date('Y-m-d H:i:s', (int)$row->end_time),
             'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
+        unset($data['sub_title']);
         if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']);
         return $data;
     }
 
     public function save(array $input): array
     {
+        unset($input['sub_title']);
+        if (isset($input['translations']) && is_array($input['translations'])) {
+            foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']);
+            unset($text);
+        }
         if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']);
         $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
         $textRules = ['translations' => ['required', 'array:zh,en,vi']];
-        $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题、副标题和正文'];
+        $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题和正文'];
         foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
-            $textRules['translations.' . $lang] = ['required', 'array:title,sub_title,content'];
+            $textRules['translations.' . $lang] = ['required', 'array:title,content'];
             $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
-            foreach (['title' => ['标题', 140], 'sub_title' => ['副标题', 200], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
+            foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
                 $key = 'translations.' . $lang . '.' . $field;
                 $textRules[$key] = ['required', 'string', 'max:' . $max];
                 $messages[$key . '.required'] = $label . $name . '不能为空';
@@ -96,7 +102,7 @@ class PromotionAdminService
     private function completeTexts(array $texts): array
     {
         foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
-            foreach (['title' => '标题', 'sub_title' => '副标题', 'content' => '正文'] as $field => $name) {
+            foreach (['title' => '标题', 'content' => '正文'] as $field => $name) {
                 $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
                 if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') {
                     throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']);

+ 1 - 2
app/Support/PromotionTranslations.php

@@ -6,7 +6,7 @@ namespace App\Support;
 final class PromotionTranslations
 {
     public const LANGUAGES = ['zh', 'en', 'vi'];
-    public const FIELDS = ['title', 'sub_title', 'content'];
+    public const FIELDS = ['title', 'content'];
 
     public static function all(array $activity): array
     {
@@ -28,7 +28,6 @@ final class PromotionTranslations
         $text = self::all($activity)[$lang] ?? null;
         if (!$text || trim($text['title']) === '') return null;
         return ['lang' => $lang, 'title' => $text['title'],
-            'sub_title' => $text['sub_title'] === '' ? null : $text['sub_title'],
             'content' => $text['content'] === '' ? null : $text['content']];
     }
 }

+ 2 - 2
docs/后端/独立活动三语文本-升级说明.md

@@ -8,11 +8,11 @@
 php artisan migrate --path=database/migrations/2026_10_27_140000_add_promotion_activity_translations.php --force
 ```
 
-本次增加 `promotion_activities.translations` JSON列,保存 `zh/en/vi` 各自的 `title/sub_title/content`。原单语言文本按原 `lang` 放入对应语言,其他语言留空;原语言无法识别时归入中文。保留活动ID、金额规则、首次上架时间和全部参与/资金记录,不添加菜单或权限。
+本次增加 `promotion_activities.translations` JSON列,保存 `zh/en/vi` 各自的 `title/content`。原单语言文本按原 `lang` 放入对应语言,其他语言留空;原语言无法识别时归入中文。保留活动ID、金额规则、首次上架时间和全部参与/资金记录,不添加菜单或权限。
 
 先在两项目共用的数据库执行新增迁移,再发布两项目接口与前端改造。未对任何运行环境执行迁移。
 
-后台保存接口要求三语全部提供且九个文本字段均非空。旧单语言表单不再接受。已有活动在补齐三语之前仍可按原语言读取;上下架接口维持 `id/status` 协议,上架额外校验三语完整,下架不受影响。
+后台保存接口要求三语全部提供且六个文本字段均非空。旧单语言表单不再接受。已有活动在补齐三语之前仍可按原语言读取;上下架接口维持 `id/status` 协议,上架额外校验三语完整,下架不受影响。
 
 后台兼容根字段由中文翻译投影生成,前端读写完整 `translations`。会员端统一按请求头 `lang`(未传头默认 `vi`)选择一套文本,URL和请求体的同名参数不参与语言选择,不再把原活动的 `lang` 当作唯一可见语言。同一活动只保留一份进度、名额、资金和领奖次数。
 

+ 11 - 19
docs/总后台/独立活动/三个活动语言Tab与文本联动-后台前端改造.md

@@ -1,18 +1,18 @@
 # 三个活动:语言Tab与文本联动改造
 
-请直接改造现有净亏邀请、首充奖励、转盘活动的新增/编辑页面。本文件是本次前端AI的执行要求;这三个页面的语言、标题、副标题、正文接入方式以本文件为准。各活动原有规则表单继续沿用各自文档。
+请直接改造现有净亏邀请、首充奖励、转盘活动的新增/编辑页面。本文件是本次前端AI的执行要求;这三个页面的语言、标题、正文接入方式以本文件为准。各活动原有规则表单继续沿用各自文档。
 
 ## 页面效果
 
 编辑同一个活动时,文本编辑区固定显示三个Tab,顺序为「中文」「英文」「越南文」,对应 `zh/en/vi`。默认打开中文Tab。
 
-每个Tab都有独立的标题、副标题、正文编辑框。切换Tab时,这三个输入框一起切换到对应语言的内容。副标题也必须按语言分别填写。语言Tab只是当前编辑视图,不是活动的一项业务属性。
+每个Tab都有独立的标题、正文编辑框。切换Tab时,这两个输入框一起切换到对应语言的内容。语言Tab只是当前编辑视图,不是活动的一项业务属性。
 
 金额、奖励规则、时间、排序、发布平台、资格限制、上下架状态在三个语言间共用,放在Tab文本区外。同一个活动始终使用同一个 `id`。净亏邀请和转盘编辑现有活动;首充奖励保留新增和编辑。
 
 净亏和转盘编辑页都没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计,不通过这个通用活动封面字段配置。
 
-保存按钮位于整个表单底部,一次提交全部三个Tab的文本和公共设置。三个Tab的标题、副标题、正文共9项全部填写后才允许保存;下架状态也不能保存不完整文本。
+保存按钮位于整个表单底部,一次提交全部三个Tab的文本和公共设置。三个Tab的标题、正文共6项全部填写后才允许保存;下架状态也不能保存不完整文本。
 
 ## 接口和数据位置
 
@@ -32,23 +32,20 @@
 {
   "zh": {
     "title": "净亏邀请奖励",
-    "sub_title": "完成净亏及邀请任务即可领取奖励",
     "content": "<p>达到活动净亏门槛后申请,再邀请符合条件的好友领取奖励。</p>"
   },
   "en": {
     "title": "Loss and Referral Reward",
-    "sub_title": "Complete the loss and referral tasks to claim your reward",
     "content": "<p>Apply after reaching the loss threshold, then invite qualifying friends to claim your reward.</p>"
   },
   "vi": {
     "title": "Thưởng thua lỗ và giới thiệu",
-    "sub_title": "Hoàn thành nhiệm vụ thua lỗ và giới thiệu để nhận thưởng",
     "content": "<p>Đăng ký sau khi đạt mức thua lỗ yêu cầu, sau đó mời bạn bè đủ điều kiện để nhận thưởng.</p>"
   }
 }
 ```
 
-固定使用 `sub_title` 字段名。列表标题优先读取 `translations.zh.title`;旧数据中文为空时可展示其已有语言标题。编辑器必须按当前Tab读取 `translations[activeLang]`。不再把根级 `lang` 绑定成语言下拉框,也不使用根级 `title/sub_title/content` 回填三个Tab。
+列表标题优先读取 `translations.zh.title`;旧数据中文为空时可展示其已有语言标题。编辑器必须按当前Tab读取 `translations[activeLang]`。不再把根级 `lang` 绑定成语言下拉框,也不使用根级 `title/content` 回填三个Tab。
 
 ## 表单状态和Tab绑定
 
@@ -60,15 +57,14 @@ const form = {
   id: 12,
   type: 7,
   translations: {
-    zh: { title: '', sub_title: '', content: '' },
-    en: { title: '', sub_title: '', content: '' },
-    vi: { title: '', sub_title: '', content: '' }
+    zh: { title: '', content: '' },
+    en: { title: '', content: '' },
+    vi: { title: '', content: '' }
   }
   // 其余公共设置沿用现有表单
 }
 // 三个输入分别绑定:
 // form.translations[activeLang].title
-// form.translations[activeLang].sub_title
 // form.translations[activeLang].content
 ```
 
@@ -84,7 +80,6 @@ const form = {
 | 每个语言Tab的字段 | 必填 | 最大长度 |
 | --- | --- | --- |
 | `title` 标题 | 是 | 140字符 |
-| `sub_title` 副标题 | 是 | 200字符 |
 | `content` 正文 | 是 | 100000字符;富文本长度包含HTML源码 |
 
 空字符串、纯空白,以及只有空标签/空格实体的正文,例如 `<p><br></p>`、`<p>&nbsp;</p>`,均不算填写完成。不得用默认占位文字、自动复制其他语言或隐藏字段来绕过校验。已有活动只填过一种语言时,显示其原有内容,另外两个Tab保持待填写,补齐后再保存。
@@ -105,17 +100,14 @@ const form = {
   "translations": {
     "zh": {
       "title": "净亏邀请奖励",
-      "sub_title": "完成净亏及邀请任务即可领取奖励",
       "content": "<p>达到净亏门槛后申请,再邀请符合条件的好友领取奖励。</p>"
     },
     "en": {
       "title": "Loss and Referral Reward",
-      "sub_title": "Complete the tasks to claim your reward",
       "content": "<p>Apply after reaching the loss threshold, then invite qualifying friends.</p>"
     },
     "vi": {
       "title": "Thưởng thua lỗ và giới thiệu",
-      "sub_title": "Hoàn thành nhiệm vụ để nhận thưởng",
       "content": "<p>Đăng ký sau khi đạt mức thua lỗ yêu cầu, sau đó mời bạn bè đủ điều kiện.</p>"
     }
   },
@@ -128,16 +120,16 @@ const form = {
 }
 ```
 
-提交完整的三个语言对象,不能只提交当前Tab,也不能按语言发送三次保存请求。根级无需再提交 `lang/title/sub_title/content`。原有其他公共设置从详情正确回填,编辑翻译时保留原值;`params` 仍为对象。
+提交完整的三个语言对象,不能只提交当前Tab,也不能按语言发送三次保存请求。根级无需再提交 `lang/title/content`。原有其他公共设置从详情正确回填,编辑翻译时保留原值;`params` 仍为对象。
 
 ## 交付验收
 
 在净亏邀请、首充奖励、转盘三个编辑页分别完成以下验证:
 
-- 三个Tab顺序、文案正确,切换后标题、副标题、正文都联动;来回切换不会丢失输入。
-- 只填写中文,或任意一个语言缺标题/副标题/正文,都不能保存,也不发出保存请求。空富文本同样被阻止。
+- 三个Tab顺序、文案正确,切换后标题、正文都联动;来回切换不会丢失输入。
+- 只填写中文,或任意一个语言缺标题/正文,都不能保存,也不发出保存请求。空富文本同样被阻止。
 - 三语全部填写后只发送一次请求,请求中包含完整 `translations.zh/en/vi`。
-- 保存后重新打开,三种语言各自显示保存的文本;只修改英文副标题后保存,中文和越南文保持原值。
+- 保存后重新打开,三种语言各自显示保存的文本;只修改英文标题后保存,中文和越南文保持原值。
 - 切换Tab时金额、奖励规则、时间、上下架状态保持一致,活动ID及列表条数不因语言增加。
 - 净亏和转盘编辑页均没有图片上传/预览控件,也不校验或提交 `image`。
 - 净亏和转盘没有新增入口,首充仍可新增;新增首充也必须完整填写三种语言。

+ 1 - 1
docs/总后台/独立活动/净亏邀请-后台接口.md

@@ -19,7 +19,7 @@
 
 列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
 
-保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
+保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
 
 可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。编辑时省略字段保留原值。
 

+ 1 - 1
docs/总后台/独立活动/转盘活动-后台接口.md

@@ -19,7 +19,7 @@
 
 列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
 
-保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
+保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
 
 可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。编辑时省略字段保留原值。
 

+ 1 - 1
docs/总后台/独立活动/首充奖励-后台接口.md

@@ -19,7 +19,7 @@
 
 列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。新增按钮和类型选择使用 `options.create_types`,当前只包含首充奖励(`value=8`);列表筛选仍可使用 `options.types` 的三种类型。
 
-保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
 
 可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
 

+ 34 - 12
tests/Integration/PromotionActivitiesTest.php

@@ -77,9 +77,9 @@ class PromotionActivitiesTest extends TestCase
     }
     private function texts(): array
     {
-        return ['zh' => ['title' => '测试活动', 'sub_title' => '规则', 'content' => '<p>中文活动正文</p>'],
-            'en' => ['title' => 'Test promotion', 'sub_title' => 'English subtitle', 'content' => '<p>English content</p>'],
-            'vi' => ['title' => 'Khuyến mãi', 'sub_title' => 'Phụ đề', 'content' => '<p>Nội dung</p>']];
+        return ['zh' => ['title' => '测试活动', 'content' => '<p>中文活动正文</p>'],
+            'en' => ['title' => 'Test promotion', 'content' => '<p>English content</p>'],
+            'vi' => ['title' => 'Khuyến mãi', 'content' => '<p>Nội dung</p>']];
     }
 
     private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
@@ -125,24 +125,46 @@ class PromotionActivitiesTest extends TestCase
         }
     }
 
+    public function test_subtitles_are_not_required_saved_or_returned_for_any_independent_activity(): void
+    {
+        $admin = new PromotionAdminService();
+        foreach ([7, 8, 9] as $type) {
+            $id = $this->activity($type); $legacy = $this->texts();
+            foreach ($legacy as &$text) $text['sub_title'] = 'Historical subtitle'; unset($text);
+            DB::table('promotion_activities')->where('id', $id)->update(['sub_title' => 'Historical subtitle', 'translations' => json_encode($legacy)]);
+            $detail = $admin->detail($id);
+            $this->assertArrayNotHasKey('sub_title', $detail);
+            foreach ($detail['translations'] as $text) $this->assertSame(['title', 'content'], array_keys($text));
+            $input = $detail; $input['sub_title'] = 'Ignored legacy input';
+            foreach ($input['translations'] as &$text) $text['sub_title'] = ['ignored legacy input']; unset($text);
+            $saved = $admin->save($input);
+            $this->assertSame($this->texts(), $saved['translations']);
+            $this->assertSame('Historical subtitle', DB::table('promotion_activities')->where('id', $id)->value('sub_title'));
+            foreach ([$saved, $admin->listing(['type' => $type])['data'][0], $admin->status($id, 1),
+                $this->service->listing(1, 'H5', 'zh', $type)['data'][0], $this->service->detail($id, 1, 'H5', 'zh')] as $row) {
+                $this->assertArrayNotHasKey('sub_title', $row);
+            }
+        }
+    }
+
     public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void
     {
         $admin = new PromotionAdminService();
         foreach ([7, 8, 9] as $type) {
             $id = $this->activity($type); $before = $admin->detail($id);
             $count = DB::table('promotion_activities')->count();
-            $input = $before; $input['translations']['en']['sub_title'] = 'Updated English subtitle';
+            $input = $before; $input['translations']['en']['title'] = 'Updated English title';
             $saved = $admin->save($input);
             $this->assertSame($id, $saved['id']);
             $this->assertSame($count, DB::table('promotion_activities')->count());
             $this->assertSame($before['translations']['zh'], $saved['translations']['zh']);
             $this->assertSame($before['translations']['vi'], $saved['translations']['vi']);
-            $this->assertSame('Updated English subtitle', $admin->detail($id)['translations']['en']['sub_title']);
+            $this->assertSame('Updated English title', $admin->detail($id)['translations']['en']['title']);
             foreach (['params', 'rewarded_count', 'budget_used', 'first_published_at'] as $field) $this->assertSame($before[$field], $saved[$field]);
             foreach (['zh', 'en', 'vi'] as $lang) {
                 $rows = $this->service->listing(1, 'H5', $lang, $type)['data'];
                 $this->assertCount(1, $rows); $this->assertSame($id, $rows[0]['activity_id']);
-                $this->assertSame($saved['translations'][$lang]['sub_title'], $rows[0]['sub_title']);
+                $this->assertSame($saved['translations'][$lang]['title'], $rows[0]['title']);
                 $state = $this->service->detail($id, 1, 'H5', $lang);
                 $this->assertSame($lang, $state['lang']);
                 $this->assertSame($saved['translations'][$lang]['title'], $state['title']);
@@ -152,12 +174,12 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(['zh', 'en', 'vi'], array_column($admin->options()['languages'], 'value'));
     }
 
-    public function test_all_nine_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void
+    public function test_all_six_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void
     {
         $id = $this->activity(9); $admin = new PromotionAdminService(); $input = $admin->detail($id);
         $before = DB::table('promotion_activities')->where('id', $id)->first();
         foreach (['zh', 'en', 'vi'] as $lang) {
-            foreach (['title', 'sub_title', 'content'] as $field) {
+            foreach (['title', 'content'] as $field) {
                 $bad = $input; unset($bad['translations'][$lang][$field]);
                 try { $admin->save($bad); $this->fail('Missing language text was accepted'); }
                 catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.' . $lang . '.' . $field)); }
@@ -172,9 +194,9 @@ class PromotionActivitiesTest extends TestCase
         $bad = $input; $bad['translations']['fr'] = $input['translations']['en'];
         try { $admin->save($bad); $this->fail('Unknown language was accepted'); }
         catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations')); }
-        $bad = $input; $bad['translations']['en']['sub_title'] = str_repeat('a', 201);
-        try { $admin->save($bad); $this->fail('Oversized subtitle was accepted'); }
-        catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.en.sub_title')); }
+        $bad = $input; $bad['translations']['en']['title'] = str_repeat('a', 141);
+        try { $admin->save($bad); $this->fail('Oversized title was accepted'); }
+        catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.en.title')); }
         $this->assertEquals($before, DB::table('promotion_activities')->where('id', $id)->first());
     }
 
@@ -229,7 +251,7 @@ class PromotionActivitiesTest extends TestCase
             foreach ((array) $row as $field => $value) $this->assertSame($value, $saved->$field);
         }
         $texts = (new PromotionAdminService())->detail((int) $id)['translations'];
-        $this->assertSame(['title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => '<p>Existing content</p>'], $texts['en']);
+        $this->assertSame(['title' => 'Existing title', 'content' => '<p>Existing content</p>'], $texts['en']);
         $this->assertSame('', $texts['zh']['title']);
         $this->assertEquals($published, DB::table('promotion_campaigns')->orderBy('activity_id')->get());
         $this->assertSame(0, DB::table('menus')->count());