From 4eba9dfc122b97a6e9fe7249308bee7b8550465f Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 11 Apr 2026 15:44:30 +0800 Subject: [PATCH] Add VIP presence themes and custom greetings --- app/Http/Controllers/Admin/VipController.php | 98 +++++++++---- app/Http/Controllers/ChatController.php | 20 ++- app/Http/Controllers/VipCenterController.php | 55 ++++++- .../UpdateVipPresenceSettingsRequest.php | 45 ++++++ app/Jobs/ProcessUserLeave.php | 24 ++- app/Models/User.php | 14 ++ app/Models/VipLevel.php | 72 +++++++++ app/Services/RoomBroadcastService.php | 50 ++++++- app/Services/VipPresenceService.php | 112 ++++++++++++++ database/factories/UserFactory.php | 2 + database/factories/VipLevelFactory.php | 5 + ...e_fields_to_vip_levels_and_users_table.php | 138 ++++++++++++++++++ public/js/effects/effect-manager.js | 14 +- resources/css/app.css | 126 ++++++++++++++++ resources/views/admin/vip/index.blade.php | 73 +++++++++ resources/views/chat/frame.blade.php | 19 ++- .../views/chat/partials/scripts.blade.php | 113 ++++++++++++++ resources/views/vip/center.blade.php | 112 ++++++++++++++ routes/web.php | 1 + tests/Feature/ChatControllerTest.php | 31 ++++ .../Feature/VipPaymentIntegrationTest.php | 51 +++++++ 21 files changed, 1126 insertions(+), 49 deletions(-) create mode 100644 app/Http/Requests/UpdateVipPresenceSettingsRequest.php create mode 100644 app/Services/VipPresenceService.php create mode 100644 database/migrations/2026_04_11_152150_add_presence_theme_fields_to_vip_levels_and_users_table.php diff --git a/app/Http/Controllers/Admin/VipController.php b/app/Http/Controllers/Admin/VipController.php index d628d89..f1f07c1 100644 --- a/app/Http/Controllers/Admin/VipController.php +++ b/app/Http/Controllers/Admin/VipController.php @@ -20,6 +20,32 @@ use Illuminate\View\View; class VipController extends Controller { + /** + * 会员主题支持的特效下拉选项。 + * + * @var array + */ + private const EFFECT_LABELS = [ + 'none' => '无特效', + 'fireworks' => '烟花', + 'rain' => '下雨', + 'lightning' => '闪电', + 'snow' => '下雪', + ]; + + /** + * 会员主题支持的横幅风格下拉选项。 + * + * @var array + */ + private const BANNER_STYLE_LABELS = [ + 'aurora' => '鎏光星幕', + 'storm' => '雷霆风暴', + 'royal' => '王者金辉', + 'cosmic' => '星穹幻彩', + 'farewell' => '告别暮光', + ]; + /** * 会员等级管理列表页 */ @@ -27,7 +53,11 @@ class VipController extends Controller { $levels = VipLevel::orderBy('sort_order')->get(); - return view('admin.vip.index', compact('levels')); + return view('admin.vip.index', [ + 'levels' => $levels, + 'effectOptions' => self::EFFECT_LABELS, + 'bannerStyleOptions' => self::BANNER_STYLE_LABELS, + ]); } /** @@ -35,22 +65,7 @@ class VipController extends Controller */ public function store(Request $request): RedirectResponse { - $data = $request->validate([ - 'name' => 'required|string|max:50', - 'icon' => 'required|string|max:20', - 'color' => 'required|string|max:10', - 'exp_multiplier' => 'required|numeric|min:1|max:99', - 'jjb_multiplier' => 'required|numeric|min:1|max:99', - 'sort_order' => 'required|integer|min:0', - 'price' => 'required|integer|min:0', - 'duration_days' => 'required|integer|min:0', - 'join_templates' => 'nullable|string', - 'leave_templates' => 'nullable|string', - ]); - - // 将文本框的多行模板转为 JSON 数组 - $data['join_templates'] = $this->textToJson($data['join_templates'] ?? ''); - $data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? ''); + $data = $this->validatedPayload($request); VipLevel::create($data); @@ -66,21 +81,7 @@ class VipController extends Controller { $level = $vip; - $data = $request->validate([ - 'name' => 'required|string|max:50', - 'icon' => 'required|string|max:20', - 'color' => 'required|string|max:10', - 'exp_multiplier' => 'required|numeric|min:1|max:99', - 'jjb_multiplier' => 'required|numeric|min:1|max:99', - 'sort_order' => 'required|integer|min:0', - 'price' => 'required|integer|min:0', - 'duration_days' => 'required|integer|min:0', - 'join_templates' => 'nullable|string', - 'leave_templates' => 'nullable|string', - ]); - - $data['join_templates'] = $this->textToJson($data['join_templates'] ?? ''); - $data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? ''); + $data = $this->validatedPayload($request); $level->update($data); @@ -119,4 +120,37 @@ class VipController extends Controller return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE); } + + /** + * 统一整理后台提交的会员等级主题配置数据。 + * + * @return array + */ + private function validatedPayload(Request $request): array + { + $data = $request->validate([ + 'name' => 'required|string|max:50', + 'icon' => 'required|string|max:20', + 'color' => 'required|string|max:10', + 'exp_multiplier' => 'required|numeric|min:1|max:99', + 'jjb_multiplier' => 'required|numeric|min:1|max:99', + 'sort_order' => 'required|integer|min:0', + 'price' => 'required|integer|min:0', + 'duration_days' => 'required|integer|min:0', + 'join_templates' => 'nullable|string', + 'leave_templates' => 'nullable|string', + 'join_effect' => 'required|in:none,fireworks,rain,lightning,snow', + 'leave_effect' => 'required|in:none,fireworks,rain,lightning,snow', + 'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell', + 'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell', + 'allow_custom_messages' => 'nullable|boolean', + ]); + + // 将多行文本框内容转为 JSON 数组,便于后续随机抽取模板。 + $data['join_templates'] = $this->textToJson($data['join_templates'] ?? ''); + $data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? ''); + $data['allow_custom_messages'] = $request->boolean('allow_custom_messages'); + + return $data; + } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index eb31c4c..656a1f7 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -41,6 +41,9 @@ use Intervention\Image\ImageManager; class ChatController extends Controller { + /** + * 构造聊天室核心控制器所需依赖。 + */ public function __construct( private readonly ChatStateService $chatState, private readonly MessageFilterService $filter, @@ -112,6 +115,7 @@ class ChatController extends Controller // 3. 广播和初始化欢迎(仅限初次进入) $newbieEffect = null; + $initialPresenceTheme = null; if (! $isAlreadyInRoom) { // 广播 UserJoined 事件,通知房间内的其他人 @@ -176,6 +180,7 @@ class ChatController extends Controller } else { // 非站长:生成通用播报(有职务 > 有VIP > 普通随机词) [$text, $color] = $this->broadcast->buildEntryBroadcast($user); + $vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join'); $generalWelcomeMsg = [ 'id' => $this->chatState->nextMessageId($id), @@ -185,13 +190,25 @@ class ChatController extends Controller 'content' => "{$text}", 'is_secret' => false, 'font_color' => $color, - 'action' => 'system_welcome', + 'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; + + // 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。 + if (! empty($vipPresencePayload)) { + $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload); + $initialPresenceTheme = $vipPresencePayload; + } + $this->chatState->pushMessage($id, $generalWelcomeMsg); // 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示 broadcast(new MessageSent($id, $generalWelcomeMsg)); + + // 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。 + if (! empty($vipPresencePayload['presence_effect'])) { + broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers(); + } } } @@ -278,6 +295,7 @@ class ChatController extends Controller 'user' => $user, 'weekEffect' => $this->shopService->getActiveWeekEffect($user), 'newbieEffect' => $newbieEffect, + 'initialPresenceTheme' => $initialPresenceTheme, 'historyMessages' => $historyMessages, 'pendingProposal' => $pendingProposalData, 'pendingDivorce' => $pendingDivorceData, diff --git a/app/Http/Controllers/VipCenterController.php b/app/Http/Controllers/VipCenterController.php index 407ab5e..ac9b0d7 100644 --- a/app/Http/Controllers/VipCenterController.php +++ b/app/Http/Controllers/VipCenterController.php @@ -2,15 +2,17 @@ /** * 文件功能:前台会员中心控制器 - * 负责展示会员等级、权益说明、当前会员状态以及用户自己的购买记录 + * 负责展示会员等级、权益说明、当前会员状态、用户购买记录与会员个性化进退场设置 */ namespace App\Http\Controllers; +use App\Http\Requests\UpdateVipPresenceSettingsRequest; use App\Models\Sysparam; use App\Models\VipLevel; use App\Models\VipPaymentOrder; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -52,9 +54,50 @@ class VipCenterController extends Controller 'vipPaymentEnabled' => Sysparam::getValue('vip_payment_enabled', '0') === '1', 'paidOrders' => $paidOrders, 'totalAmount' => $totalAmount, + 'effectOptions' => [ + 'none' => '无特效', + 'fireworks' => '烟花', + 'rain' => '下雨', + 'lightning' => '闪电', + 'snow' => '下雪', + ], + 'bannerStyleOptions' => [ + 'aurora' => '鎏光星幕', + 'storm' => '雷霆风暴', + 'royal' => '王者金辉', + 'cosmic' => '星穹幻彩', + 'farewell' => '告别暮光', + ], ]); } + /** + * 保存会员个人自定义欢迎语与离开语。 + */ + public function updatePresenceSettings(UpdateVipPresenceSettingsRequest $request): RedirectResponse + { + $user = $request->user(); + + // 只有有效会员且当前等级允许自定义时,才允许保存专属语句。 + if (! $user->canCustomizeVipPresence()) { + return redirect() + ->route('vip.center') + ->with('error', '当前会员等级暂不支持自定义欢迎语和离开语。'); + } + + $data = $request->validated(); + + // 空字符串统一转成 null,避免数据库保存无意义空白值。 + $user->update([ + 'custom_join_message' => $this->sanitizeNullableMessage($data['custom_join_message'] ?? null), + 'custom_leave_message' => $this->sanitizeNullableMessage($data['custom_leave_message'] ?? null), + ]); + + return redirect() + ->route('vip.center') + ->with('success', '会员专属欢迎语和离开语已保存。'); + } + /** * 构建当前用户的购买记录分页数据 * @@ -69,4 +112,14 @@ class VipCenterController extends Controller ->paginate(10) ->withQueryString(); } + + /** + * 将可空文案统一整理为数据库可保存的字符串。 + */ + private function sanitizeNullableMessage(?string $message): ?string + { + $message = trim((string) $message); + + return $message === '' ? null : $message; + } } diff --git a/app/Http/Requests/UpdateVipPresenceSettingsRequest.php b/app/Http/Requests/UpdateVipPresenceSettingsRequest.php new file mode 100644 index 0000000..97fca8d --- /dev/null +++ b/app/Http/Requests/UpdateVipPresenceSettingsRequest.php @@ -0,0 +1,45 @@ +user() !== null; + } + + /** + * 获取会员个性化设置的验证规则。 + */ + public function rules(): array + { + return [ + 'custom_join_message' => ['nullable', 'string', 'max:255'], + 'custom_leave_message' => ['nullable', 'string', 'max:255'], + ]; + } + + /** + * 获取会员个性化设置的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'custom_join_message.max' => '欢迎语最多只能填写 255 个字符。', + 'custom_leave_message.max' => '离开语最多只能填写 255 个字符。', + ]; + } +} diff --git a/app/Jobs/ProcessUserLeave.php b/app/Jobs/ProcessUserLeave.php index 4182fb2..e3a3cd0 100644 --- a/app/Jobs/ProcessUserLeave.php +++ b/app/Jobs/ProcessUserLeave.php @@ -1,5 +1,10 @@ buildLeaveBroadcast($this->user); + $vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave'); $leaveMsg = [ 'id' => $chatState->nextMessageId($this->roomId), 'room_id' => $this->roomId, @@ -74,16 +86,26 @@ class ProcessUserLeave implements ShouldQueue 'content' => "{$leaveText}", 'is_secret' => false, 'font_color' => $color, - 'action' => 'system_welcome', + 'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence', 'welcome_user' => $this->user->username, 'sent_at' => now()->toDateTimeString(), ]; + + // 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。 + if (! empty($vipPresencePayload)) { + $leaveMsg = array_merge($leaveMsg, $vipPresencePayload); + } } // 将播报存入 Redis 历史及广播 $chatState->pushMessage($this->roomId, $leaveMsg); broadcast(new \App\Events\UserLeft($this->roomId, $this->user->username))->toOthers(); broadcast(new \App\Events\MessageSent($this->roomId, $leaveMsg))->toOthers(); + + // 离场特效单独发送给房间内仍在线的其他人,避免和消息播报逻辑耦死。 + if (! empty($leaveMsg['presence_effect'])) { + broadcast(new \App\Events\EffectBroadcast($this->roomId, $leaveMsg['presence_effect'], $this->user->username))->toOthers(); + } } /** diff --git a/app/Models/User.php b/app/Models/User.php index 31be5a3..d624e85 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -35,6 +35,8 @@ class User extends Authenticatable 'email', 'sex', 'sign', + 'custom_join_message', + 'custom_leave_message', 'user_level', 'inviter_id', 'room_id', @@ -197,6 +199,18 @@ class User extends Authenticatable return $this->vipLevel?->icon ?? ''; } + /** + * 判断用户当前是否允许自定义会员进退场语句。 + */ + public function canCustomizeVipPresence(): bool + { + if (! $this->isVip()) { + return false; + } + + return (bool) $this->vipLevel?->allow_custom_messages; + } + /** * 关联:当前用户的 VIP 购买订单记录 */ diff --git a/app/Models/VipLevel.php b/app/Models/VipLevel.php index f4a9181..2254eab 100644 --- a/app/Models/VipLevel.php +++ b/app/Models/VipLevel.php @@ -20,6 +20,32 @@ class VipLevel extends Model { use HasFactory; + /** + * 会员进退场支持的特效选项。 + * + * @var array + */ + public const EFFECT_OPTIONS = [ + 'none', + 'fireworks', + 'rain', + 'lightning', + 'snow', + ]; + + /** + * 会员进退场支持的横幅风格选项。 + * + * @var array + */ + public const BANNER_STYLE_OPTIONS = [ + 'aurora', + 'storm', + 'royal', + 'cosmic', + 'farewell', + ]; + /** @var string 表名 */ protected $table = 'vip_levels'; @@ -32,6 +58,11 @@ class VipLevel extends Model 'jjb_multiplier', 'join_templates', 'leave_templates', + 'join_effect', + 'leave_effect', + 'join_banner_style', + 'leave_banner_style', + 'allow_custom_messages', 'sort_order', 'price', 'duration_days', @@ -44,6 +75,7 @@ class VipLevel extends Model 'sort_order' => 'integer', 'price' => 'integer', 'duration_days' => 'integer', + 'allow_custom_messages' => 'boolean', ]; /** @@ -98,4 +130,44 @@ class VipLevel extends Model return str_replace('{username}', $username, $template); } + + /** + * 获取规范化后的入场特效键名。 + */ + public function joinEffectKey(): string + { + return in_array($this->join_effect, self::EFFECT_OPTIONS, true) + ? (string) $this->join_effect + : 'none'; + } + + /** + * 获取规范化后的离场特效键名。 + */ + public function leaveEffectKey(): string + { + return in_array($this->leave_effect, self::EFFECT_OPTIONS, true) + ? (string) $this->leave_effect + : 'none'; + } + + /** + * 获取规范化后的入场横幅风格键名。 + */ + public function joinBannerStyleKey(): string + { + return in_array($this->join_banner_style, self::BANNER_STYLE_OPTIONS, true) + ? (string) $this->join_banner_style + : 'aurora'; + } + + /** + * 获取规范化后的离场横幅风格键名。 + */ + public function leaveBannerStyleKey(): string + { + return in_array($this->leave_banner_style, self::BANNER_STYLE_OPTIONS, true) + ? (string) $this->leave_banner_style + : 'farewell'; + } } diff --git a/app/Services/RoomBroadcastService.php b/app/Services/RoomBroadcastService.php index 24ee2f1..7fd0452 100644 --- a/app/Services/RoomBroadcastService.php +++ b/app/Services/RoomBroadcastService.php @@ -16,10 +16,10 @@ use App\Models\User; class RoomBroadcastService { /** - * 构造函数注入 VIP 服务(用于获取 VIP 专属入场/离场模板) + * 构造函数注入会员进退场主题服务。 */ public function __construct( - private readonly VipService $vipService, + private readonly VipPresenceService $vipPresenceService, ) {} /** @@ -43,11 +43,12 @@ class RoomBroadcastService // 有 VIP:优先用专属进入模板,无模板则随机词加前缀 if ($user->isVip() && $user->vipLevel) { - $color = $user->vipLevel->color ?: '#f59e0b'; - $template = $this->vipService->getJoinMessage($user); + $theme = $this->vipPresenceService->buildJoinTheme($user); + $color = $theme['color'] ?: '#f59e0b'; + $template = $theme['text']; if ($template) { - return [$template, $color]; + return [(string) $template, $color]; } $text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomWelcomeMsg($user); @@ -80,11 +81,12 @@ class RoomBroadcastService // 有 VIP:优先用专属离场模板,无模板则随机词加前缀 if ($user->isVip() && $user->vipLevel) { - $color = $user->vipLevel->color ?: '#f59e0b'; - $template = $this->vipService->getLeaveMessage($user); + $theme = $this->vipPresenceService->buildLeaveTheme($user); + $color = $theme['color'] ?: '#f59e0b'; + $template = $theme['text']; if ($template) { - return [$template, $color]; + return [(string) $template, $color]; } $text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomLeaveMsg($user); @@ -149,4 +151,36 @@ class RoomBroadcastService return $templates[array_rand($templates)]; } + + /** + * 构建会员进退场横幅与特效的前端载荷。 + * + * @param string $type join|leave + * @return array + */ + public function buildVipPresencePayload(User $user, string $type): array + { + $theme = $type === 'join' + ? $this->vipPresenceService->buildJoinTheme($user) + : $this->vipPresenceService->buildLeaveTheme($user); + + if (empty($theme['enabled'])) { + return []; + } + + $text = trim((string) ($theme['text'] ?? '')); + if ($text === '') { + return []; + } + + return [ + 'presence_type' => $type, + 'presence_text' => $text, + 'presence_color' => (string) ($theme['color'] ?? ''), + 'presence_effect' => $theme['effect'] ? (string) $theme['effect'] : null, + 'presence_banner_style' => (string) ($theme['banner_style'] ?? ''), + 'presence_level_name' => (string) ($theme['level_name'] ?? ''), + 'presence_icon' => (string) ($theme['icon'] ?? ''), + ]; + } } diff --git a/app/Services/VipPresenceService.php b/app/Services/VipPresenceService.php new file mode 100644 index 0000000..7cb0b4f --- /dev/null +++ b/app/Services/VipPresenceService.php @@ -0,0 +1,112 @@ + + */ + public function buildJoinTheme(User $user): array + { + return $this->buildTheme($user, 'join'); + } + + /** + * 构建会员离场主题数据。 + * + * @return array + */ + public function buildLeaveTheme(User $user): array + { + return $this->buildTheme($user, 'leave'); + } + + /** + * 统一构建会员进场或离场的主题数据。 + * + * @param string $type join|leave + * @return array + */ + private function buildTheme(User $user, string $type): array + { + $vipLevel = $user->vipLevel; + + if (! $user->isVip() || ! $vipLevel) { + return [ + 'enabled' => false, + 'type' => $type, + 'text' => null, + 'color' => null, + 'effect' => null, + 'banner_style' => null, + 'level_name' => null, + 'icon' => null, + ]; + } + + // 先读取个人自定义文案,只有等级允许时才参与覆盖。 + $customMessage = $type === 'join' + ? $this->formatCustomMessage($user->custom_join_message, $user->username) + : $this->formatCustomMessage($user->custom_leave_message, $user->username); + + if (! $user->canCustomizeVipPresence()) { + $customMessage = null; + } + + // 如果用户没有填写自定义文案,则回退到等级模板。 + $templateMessage = $type === 'join' + ? $this->vipService->getJoinMessage($user) + : $this->vipService->getLeaveMessage($user); + + return [ + 'enabled' => true, + 'type' => $type, + 'text' => $customMessage ?: $templateMessage, + 'color' => $vipLevel->color ?: '#f59e0b', + 'effect' => $type === 'join' ? $this->normalizeEffect($vipLevel->joinEffectKey()) : $this->normalizeEffect($vipLevel->leaveEffectKey()), + 'banner_style' => $type === 'join' ? $vipLevel->joinBannerStyleKey() : $vipLevel->leaveBannerStyleKey(), + 'level_name' => $vipLevel->name, + 'icon' => $vipLevel->icon, + ]; + } + + /** + * 格式化用户自定义文案,支持 {username} 占位符。 + */ + private function formatCustomMessage(?string $message, string $username): ?string + { + $message = trim((string) $message); + + if ($message === '') { + return null; + } + + return str_replace('{username}', $username, $message); + } + + /** + * 把 none 这类占位值转换为 null,方便外部判断是否要播特效。 + */ + private function normalizeEffect(string $effect): ?string + { + return $effect === 'none' ? null : $effect; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 744f580..f2b87b7 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,8 @@ class UserFactory extends Factory 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'sex' => 1, + 'custom_join_message' => null, + 'custom_leave_message' => null, 'user_level' => 1, ]; } diff --git a/database/factories/VipLevelFactory.php b/database/factories/VipLevelFactory.php index 39f8d85..a50f3e4 100644 --- a/database/factories/VipLevelFactory.php +++ b/database/factories/VipLevelFactory.php @@ -29,6 +29,11 @@ class VipLevelFactory extends Factory 'jjb_multiplier' => 1.2, 'join_templates' => null, 'leave_templates' => null, + 'join_effect' => 'none', + 'leave_effect' => 'none', + 'join_banner_style' => 'aurora', + 'leave_banner_style' => 'farewell', + 'allow_custom_messages' => true, 'sort_order' => fake()->numberBetween(1, 20), 'price' => fake()->numberBetween(10, 99), 'duration_days' => 30, diff --git a/database/migrations/2026_04_11_152150_add_presence_theme_fields_to_vip_levels_and_users_table.php b/database/migrations/2026_04_11_152150_add_presence_theme_fields_to_vip_levels_and_users_table.php new file mode 100644 index 0000000..66a423d --- /dev/null +++ b/database/migrations/2026_04_11_152150_add_presence_theme_fields_to_vip_levels_and_users_table.php @@ -0,0 +1,138 @@ +string('join_effect', 30)->nullable()->after('leave_templates')->comment('会员入场特效类型'); + $table->string('leave_effect', 30)->nullable()->after('join_effect')->comment('会员离场特效类型'); + $table->string('join_banner_style', 30)->default('aurora')->after('leave_effect')->comment('会员入场横幅风格'); + $table->string('leave_banner_style', 30)->default('farewell')->after('join_banner_style')->comment('会员离场横幅风格'); + $table->boolean('allow_custom_messages')->default(true)->after('leave_banner_style')->comment('是否允许会员自定义欢迎语和离开语'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->string('custom_join_message', 255)->nullable()->after('sign')->comment('用户自定义欢迎语'); + $table->string('custom_leave_message', 255)->nullable()->after('custom_join_message')->comment('用户自定义离开语'); + }); + + // 为现有 4 档会员预设不同的入场/离场语句、特效与横幅风格,确保开箱即用。 + $this->seedVipPresenceThemes(); + } + + /** + * 回滚迁移:删除会员进退场主题相关字段。 + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'custom_join_message', + 'custom_leave_message', + ]); + }); + + Schema::table('vip_levels', function (Blueprint $table) { + $table->dropColumn([ + 'join_effect', + 'leave_effect', + 'join_banner_style', + 'leave_banner_style', + 'allow_custom_messages', + ]); + }); + } + + /** + * 为现有会员等级回填默认进退场主题配置。 + */ + private function seedVipPresenceThemes(): void + { + $themes = [ + 1 => [ + 'join_effect' => 'snow', + 'leave_effect' => 'rain', + 'join_banner_style' => 'aurora', + 'leave_banner_style' => 'farewell', + 'join_templates' => json_encode([ + '白银贵宾 {username} 披着月色缓缓入场,银辉点亮了今晚的聊天室。', + '{username} 佩着白银徽章轻轻登场,清风与掌声一并来到。', + '欢迎白银会员 {username} 闪亮现身,今晚的好心情从此刻开始。', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '白银贵宾 {username} 挥挥手离场,留下一地温柔星光。', + '{username} 踩着银色余晖优雅退场,期待下次再会。', + '白银会员 {username} 已悄然离席,聊天室仍留着 TA 的温度。', + ], JSON_UNESCAPED_UNICODE), + ], + 2 => [ + 'join_effect' => 'rain', + 'leave_effect' => 'snow', + 'join_banner_style' => 'storm', + 'leave_banner_style' => 'aurora', + 'join_templates' => json_encode([ + '黄金贵宾 {username} 踏着流金光幕入场,整个房间都亮了起来。', + '{username} 驾着黄金座驾高调现身,全场目光瞬间聚焦。', + '欢迎黄金会员 {username} 荣耀登场,今夜的热度正式拉满。', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '黄金贵宾 {username} 在掌声与光束中谢幕离场。', + '{username} 留下一抹鎏金背影,从容地走出了今晚的高光。', + '黄金会员 {username} 已优雅退场,华丽气场仍在场中回荡。', + ], JSON_UNESCAPED_UNICODE), + ], + 3 => [ + 'join_effect' => 'lightning', + 'leave_effect' => 'rain', + 'join_banner_style' => 'cosmic', + 'leave_banner_style' => 'storm', + 'join_templates' => json_encode([ + '钻石贵宾 {username} 伴着星海电光降临,璀璨得令人移不开眼。', + '{username} 驾驭钻石流光闪耀入场,聊天室气氛瞬间拉到满格。', + '欢迎钻石会员 {username} 华彩登场,这一刻全场都被点亮。', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '钻石贵宾 {username} 化作一束流星光影,耀眼地离开了舞台。', + '{username} 留下满屏星辉后优雅退场,仿佛银河刚刚经过。', + '钻石会员 {username} 已离场,璀璨余韵仍在聊天室回响。', + ], JSON_UNESCAPED_UNICODE), + ], + 4 => [ + 'join_effect' => 'fireworks', + 'leave_effect' => 'lightning', + 'join_banner_style' => 'royal', + 'leave_banner_style' => 'cosmic', + 'join_templates' => json_encode([ + '至尊会员 {username} 御光而来,王者气场瞬间笼罩全场。', + '请注意,至尊贵宾 {username} 已荣耀驾临,今晚高光正式开启。', + '{username} 身披王者金辉震撼登场,整个聊天室都在为 TA 让路。', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '至尊会员 {username} 在雷光与礼赞中谢幕离场,气场依旧未散。', + '{username} 留下一道王者余辉后从容退场,全场仍沉浸在震撼之中。', + '至尊贵宾 {username} 已离席,聊天室却还回响着 TA 的登场气势。', + ], JSON_UNESCAPED_UNICODE), + ], + ]; + + foreach ($themes as $sortOrder => $theme) { + DB::table('vip_levels') + ->where('sort_order', $sortOrder) + ->update($theme); + } + } +}; diff --git a/public/js/effects/effect-manager.js b/public/js/effects/effect-manager.js index 2f1d3fc..0aa1523 100644 --- a/public/js/effects/effect-manager.js +++ b/public/js/effects/effect-manager.js @@ -10,6 +10,8 @@ const EffectManager = (() => { let _current = null; // 全屏 Canvas 元素引用 let _canvas = null; + // 待播放特效队列,避免多个进场效果互相打断 + const _queue = []; /** * 获取或创建全屏 Canvas 元素 @@ -50,6 +52,13 @@ const EffectManager = (() => { if (typeof EffectSounds !== "undefined") { EffectSounds.stop(); } + + if (_queue.length > 0) { + const nextType = _queue.shift(); + if (nextType) { + play(nextType); + } + } } /** @@ -60,9 +69,8 @@ const EffectManager = (() => { function play(type) { // 防重入:同时只允许一个特效 if (_current) { - console.log( - `[EffectManager] 特效 ${_current} 正在播放,忽略 ${type}`, - ); + console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`); + _queue.push(type); return; } diff --git a/resources/css/app.css b/resources/css/app.css index 3e6abea..45fde76 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -9,3 +9,129 @@ --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } + +.vip-presence-banner { + position: fixed; + inset: 24px 24px auto 24px; + z-index: 100000; + display: flex; + justify-content: center; + pointer-events: none; + animation: vip-presence-enter .55s ease-out both; +} + +.vip-presence-banner.is-leaving { + animation: vip-presence-leave .65s ease-in both; +} + +.vip-presence-banner__glow { + position: absolute; + inset: 14px auto auto 50%; + width: min(72vw, 720px); + height: 88px; + border-radius: 9999px; + filter: blur(34px); + transform: translateX(-50%); + opacity: .95; +} + +.vip-presence-banner__card { + position: relative; + width: min(92vw, 760px); + border: 1px solid rgba(255, 255, 255, .35); + border-radius: 28px; + padding: 20px 24px; + overflow: hidden; + box-shadow: 0 20px 60px rgba(15, 23, 42, .35); +} + +.vip-presence-banner__card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(120deg, transparent 0%, rgba(255,255,255,.16) 38%, transparent 72%); + transform: translateX(-120%); + animation: vip-presence-shine 2.6s ease-in-out infinite; +} + +.vip-presence-banner__meta { + position: relative; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.vip-presence-banner__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 16px; + background: rgba(15, 23, 42, .22); + backdrop-filter: blur(10px); + font-size: 24px; +} + +.vip-presence-banner__level { + font-size: 12px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; + color: rgba(255, 255, 255, .92); +} + +.vip-presence-banner__type { + font-size: 11px; + font-weight: 700; + padding: 6px 10px; + border-radius: 9999px; + color: #0f172a; + background: rgba(255, 255, 255, .72); +} + +.vip-presence-banner__text { + position: relative; + margin-top: 14px; + font-size: clamp(16px, 2vw, 24px); + font-weight: 800; + line-height: 1.5; + text-wrap: balance; + text-shadow: 0 2px 18px rgba(15, 23, 42, .22); +} + +@keyframes vip-presence-enter { + from { + opacity: 0; + transform: translateY(-22px) scale(.96); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes vip-presence-leave { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateY(-16px) scale(.98); + } +} + +@keyframes vip-presence-shine { + 0% { + transform: translateX(-120%); + } + + 55%, + 100% { + transform: translateX(140%); + } +} diff --git a/resources/views/admin/vip/index.blade.php b/resources/views/admin/vip/index.blade.php index c8e5d7b..ccd8d0e 100644 --- a/resources/views/admin/vip/index.blade.php +++ b/resources/views/admin/vip/index.blade.php @@ -27,6 +27,11 @@ duration_days: 30, join_templates: '', leave_templates: '', + join_effect: 'none', + leave_effect: 'none', + join_banner_style: 'aurora', + leave_banner_style: 'farewell', + allow_custom_messages: true, }, openCreate() { @@ -42,6 +47,11 @@ duration_days: 30, join_templates: '', leave_templates: '', + join_effect: 'none', + leave_effect: 'none', + join_banner_style: 'aurora', + leave_banner_style: 'farewell', + allow_custom_messages: true, }; this.showForm = true; }, @@ -59,6 +69,11 @@ duration_days: level.duration_days, join_templates: level.join_templates_text, leave_templates: level.leave_templates_text, + join_effect: level.join_effect, + leave_effect: level.leave_effect, + join_banner_style: level.join_banner_style, + leave_banner_style: level.leave_banner_style, + allow_custom_messages: level.allow_custom_messages, }; this.showForm = true; } @@ -112,6 +127,18 @@ 当前会员 {{ $level->users()->count() }} 人 +
+ 进场特效 + {{ $effectOptions[$level->joinEffectKey()] ?? '无特效' }} +
+
+ 离场特效 + {{ $effectOptions[$level->leaveEffectKey()] ?? '无特效' }} +
+
+ 允许自定义 + {{ $level->allow_custom_messages ? '允许' : '关闭' }} +
@@ -127,6 +154,11 @@ sort_order: {{ $level->sort_order }}, price: {{ $level->price }}, duration_days: {{ $level->duration_days }}, + join_effect: '{{ $level->joinEffectKey() }}', + leave_effect: '{{ $level->leaveEffectKey() }}', + join_banner_style: '{{ $level->joinBannerStyleKey() }}', + leave_banner_style: '{{ $level->leaveBannerStyleKey() }}', + allow_custom_messages: {{ $level->allow_custom_messages ? 'true' : 'false' }}, join_templates_text: `{{ str_replace('`', '', implode("\n", $level->join_templates_array)) }}`, leave_templates_text: `{{ str_replace('`', '', implode("\n", $level->leave_templates_array)) }}`, requestUrl: '{{ route('admin.vip.update', $level->id) }}' @@ -229,6 +261,47 @@ class="w-full border rounded-md p-2 text-sm"> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 822cd23..73f274e 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -232,16 +232,18 @@ }); @endif - @if (!empty($newbieEffect) || !empty($weekEffect)) + @if (!empty($newbieEffect) || !empty($weekEffect) || !empty($initialPresenceTheme['presence_effect'])) @endif + @if (!empty($initialPresenceTheme)) + + @endif {{-- 页面初始加载时,若存在挂起的求婚 / 离婚请求,则弹窗 --}} @if (!empty($pendingProposal) || !empty($pendingDivorce)) diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 8785fad..be5f244 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -68,6 +68,89 @@ let autoScroll = true; let _maxMsgId = 0; // 记录当前收到的最大消息 ID + /** + * 转义会员横幅文本,避免横幅层被注入 HTML。 + */ + function escapePresenceText(text) { + return escapeHtml(String(text ?? '')).replace(/\n/g, '
'); + } + + /** + * 根据不同的会员横幅风格返回渐变与光影配置。 + */ + function getVipPresenceStyleConfig(style, color) { + const fallback = color || '#f59e0b'; + + const map = { + aurora: { + gradient: `linear-gradient(135deg, ${fallback}, #fde68a, #fff7ed)`, + glow: `${fallback}66`, + accent: '#fff7ed', + }, + storm: { + gradient: `linear-gradient(135deg, #1e3a8a, ${fallback}, #dbeafe)`, + glow: '#60a5fa88', + accent: '#dbeafe', + }, + royal: { + gradient: `linear-gradient(135deg, #111827, ${fallback}, #fbbf24)`, + glow: '#fbbf2488', + accent: '#fef3c7', + }, + cosmic: { + gradient: `linear-gradient(135deg, #312e81, ${fallback}, #ec4899)`, + glow: '#c084fc99', + accent: '#f5d0fe', + }, + farewell: { + gradient: `linear-gradient(135deg, #334155, ${fallback}, #94a3b8)`, + glow: '#cbd5e188', + accent: '#f8fafc', + }, + }; + + return map[style] || map.aurora; + } + + /** + * 显示会员进退场豪华横幅。 + */ + function showVipPresenceBanner(payload) { + if (!payload || !payload.presence_text) { + return; + } + + const existing = document.getElementById('vip-presence-banner'); + if (existing) { + existing.remove(); + } + + const styleConfig = getVipPresenceStyleConfig(payload.presence_banner_style, payload.presence_color); + const banner = document.createElement('div'); + banner.id = 'vip-presence-banner'; + banner.className = 'vip-presence-banner'; + banner.innerHTML = ` +
+
+
+ ${escapeHtml(payload.presence_icon || '👑')} + ${escapeHtml(payload.presence_level_name || '尊贵会员')} + ${payload.presence_type === 'leave' ? '离场提示' : '闪耀登场'} +
+
${escapePresenceText(payload.presence_text)}
+
+ `; + + document.body.appendChild(banner); + + setTimeout(() => { + banner.classList.add('is-leaving'); + setTimeout(() => banner.remove(), 700); + }, 4200); + } + + window.showVipPresenceBanner = showVipPresenceBanner; + // ── Tab 切换 ────────────────────────────────────── let _roomsRefreshTimer = null; @@ -539,6 +622,31 @@ html = `${iconImg} ${parsedContent}`; } + // 会员专属进退场播报:更醒目的卡片化样式,同时由外层额外触发豪华横幅。 + else if (msg.action === 'vip_presence') { + div.style.cssText = + 'background:linear-gradient(135deg, rgba(15,23,42,.96), rgba(30,41,59,.9)); border:1px solid rgba(255,255,255,.14); border-radius:12px; padding:10px 12px; margin:6px 0; box-shadow:0 10px 26px rgba(15,23,42,.22);'; + const icon = escapeHtml(msg.presence_icon || '👑'); + const levelName = escapeHtml(msg.presence_level_name || '尊贵会员'); + const typeLabel = msg.presence_type === 'leave' ? '华丽离场' : '荣耀入场'; + const accent = msg.presence_color || '#f59e0b'; + const safeText = escapePresenceText(msg.presence_text || ''); + + html = ` +
+
${icon}
+
+
+ ${typeLabel} + ${levelName} + (${timeStr}) +
+
${safeText}
+
+
+ `; + timeStrOverride = true; + } // 贾妖语 —— 蓝色左边框渐变样式,比 系统公告 低调 else if (msg.action === '欢迎') { div.style.cssText = @@ -759,6 +867,11 @@ return; } appendMessage(msg); + + if (msg.action === 'vip_presence') { + showVipPresenceBanner(msg); + } + // 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片 if (msg.toast_notification && msg.to_user === window.chatContext.username) { const t = msg.toast_notification; diff --git a/resources/views/vip/center.blade.php b/resources/views/vip/center.blade.php index 78093bf..23364d0 100644 --- a/resources/views/vip/center.blade.php +++ b/resources/views/vip/center.blade.php @@ -139,6 +139,118 @@
+
+
+
+
+

会员进退场主题

+

欢迎语、离开语与专属入场仪式

+

+ 这里可以查看当前会员档位的专属特效和横幅风格;若当前档位允许自定义,你还可以设置自己的欢迎语和离开语。 +

+
+ @if ($user->isVip()) +
+ 当前档位:{{ $user->vipName() }} +
+ @endif +
+
+ +
+
+
+
+
+ {{ $user->vipLevel?->icon ?: '✨' }} +
+
+

当前主题预览

+

{{ $user->vipLevel?->name ?? '普通用户' }}

+
+
+ +
+
+

入场特效

+

{{ $effectOptions[$user->vipLevel?->joinEffectKey() ?? 'none'] ?? '无特效' }}

+

横幅风格:{{ $bannerStyleOptions[$user->vipLevel?->joinBannerStyleKey() ?? 'aurora'] ?? '鎏光星幕' }}

+
+
+

离场特效

+

{{ $effectOptions[$user->vipLevel?->leaveEffectKey() ?? 'none'] ?? '无特效' }}

+

横幅风格:{{ $bannerStyleOptions[$user->vipLevel?->leaveBannerStyleKey() ?? 'farewell'] ?? '告别暮光' }}

+
+
+
+ +
+

等级默认语句

+
+
+

默认欢迎语

+

+ {{ $user->vipLevel?->join_templates_array[0] ?? '当前档位尚未配置默认欢迎语。' }} +

+
+
+

默认离开语

+

+ {{ $user->vipLevel?->leave_templates_array[0] ?? '当前档位尚未配置默认离开语。' }} +

+
+
+
+
+ +
+
+
+

我的个性化设置

+

自定义欢迎语与离开语

+
+ @if ($user->canCustomizeVipPresence()) + 已开启 + @else + 未开放 + @endif +
+ + @if ($user->canCustomizeVipPresence()) +
+ @csrf + @method('PUT') +
+ + +

支持使用 {username} 占位符自动替换成你的昵称。

+
+
+ + +
+ +
+ @elseif ($user->isVip()) +
+ 当前会员档位暂未开放个人自定义功能,不过你仍会自动使用本等级配置的专属欢迎语、离开语和华丽特效。 +
+ @else +
+ 开通会员后,这里会解锁对应等级的专属进退场主题;若等级允许,还能设置你自己的欢迎语和离开语。 +
+ @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 6dce85a..c807be2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -35,6 +35,7 @@ Route::middleware(['chat.auth'])->group(function () { // ---- 第六阶段:大厅与房间管理 ---- Route::get('/guide', fn () => view('rooms.guide'))->name('guide'); Route::get('/vip-center', [\App\Http\Controllers\VipCenterController::class, 'index'])->name('vip.center'); + Route::put('/vip-center/presence-settings', [\App\Http\Controllers\VipCenterController::class, 'updatePresenceSettings'])->name('vip.center.presence.update'); // ---- VIP 在线支付 ---- Route::post('/vip/payment', [\App\Http\Controllers\VipPaymentController::class, 'store'])->name('vip.payment.store'); diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index c59e81e..1c571ff 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -98,6 +98,37 @@ class ChatControllerTest extends TestCase $this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username)); } + /** + * 测试会员用户首次进房时会把专属欢迎主题写入历史消息。 + */ + public function test_vip_user_join_message_uses_presence_theme_payload(): void + { + $room = Room::create(['room_name' => 'vip_theme_room']); + $vipLevel = \App\Models\VipLevel::factory()->create([ + 'join_effect' => 'lightning', + 'join_banner_style' => 'storm', + 'allow_custom_messages' => true, + ]); + $user = User::factory()->create([ + 'vip_level_id' => $vipLevel->id, + 'hy_time' => now()->addDays(30), + 'custom_join_message' => '{username} 带着风暴王座闪耀降临', + 'has_received_new_gift' => true, + ]); + + $response = $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response->assertStatus(200); + $history = $response->viewData('historyMessages'); + $presenceMessage = collect($history)->first(fn (array $message) => ($message['action'] ?? '') === 'vip_presence'); + + $this->assertNotNull($presenceMessage); + $this->assertSame('join', $presenceMessage['presence_type']); + $this->assertSame('lightning', $presenceMessage['presence_effect']); + $this->assertSame('storm', $presenceMessage['presence_banner_style']); + $this->assertStringContainsString($user->username, $presenceMessage['presence_text']); + } + public function test_can_get_rooms_online_status() { $user = User::factory()->create(); diff --git a/tests/Feature/Feature/VipPaymentIntegrationTest.php b/tests/Feature/Feature/VipPaymentIntegrationTest.php index 102c922..17e73bf 100644 --- a/tests/Feature/Feature/VipPaymentIntegrationTest.php +++ b/tests/Feature/Feature/VipPaymentIntegrationTest.php @@ -174,6 +174,57 @@ class VipPaymentIntegrationTest extends TestCase $response->assertSee('我的购买记录'); } + /** + * 测试允许自定义的会员可以在会员中心保存自己的欢迎语和离开语。 + */ + public function test_vip_member_can_update_custom_presence_messages(): void + { + $vipLevel = VipLevel::factory()->create([ + 'allow_custom_messages' => true, + ]); + $user = User::factory()->create([ + 'vip_level_id' => $vipLevel->id, + 'hy_time' => now()->addDays(30), + ]); + + $response = $this->actingAs($user)->put(route('vip.center.presence.update'), [ + 'custom_join_message' => '{username} 乘着流光闪耀登场', + 'custom_leave_message' => '{username} 留下一缕星辉悄然退场', + ]); + + $response->assertRedirect(route('vip.center')); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'custom_join_message' => '{username} 乘着流光闪耀登场', + 'custom_leave_message' => '{username} 留下一缕星辉悄然退场', + ]); + } + + /** + * 测试未开通该权限的用户不能保存自定义欢迎语和离开语。 + */ + public function test_non_customizable_vip_member_cannot_update_custom_presence_messages(): void + { + $vipLevel = VipLevel::factory()->create([ + 'allow_custom_messages' => false, + ]); + $user = User::factory()->create([ + 'vip_level_id' => $vipLevel->id, + 'hy_time' => now()->addDays(30), + ]); + + $response = $this->actingAs($user)->put(route('vip.center.presence.update'), [ + 'custom_join_message' => '不应被保存', + 'custom_leave_message' => '不应被保存', + ]); + + $response->assertRedirect(route('vip.center')); + $this->assertDatabaseMissing('users', [ + 'id' => $user->id, + 'custom_join_message' => '不应被保存', + ]); + } + /** * 写入测试所需的支付中心配置 */