Add VIP presence themes and custom greetings

This commit is contained in:
2026-04-11 15:44:30 +08:00
parent 9fb7710079
commit 4eba9dfc12
21 changed files with 1126 additions and 49 deletions

View File

@@ -20,6 +20,32 @@ use Illuminate\View\View;
class VipController extends Controller
{
/**
* 会员主题支持的特效下拉选项。
*
* @var array<string, string>
*/
private const EFFECT_LABELS = [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
];
/**
* 会员主题支持的横幅风格下拉选项。
*
* @var array<string, string>
*/
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<string, mixed>
*/
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;
}
}

View File

@@ -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' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'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,

View File

@@ -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;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* 文件功能:会员个性化欢迎语与离开语设置验证器
* 负责校验会员中心提交的自定义进退场文案。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVipPresenceSettingsRequest extends FormRequest
{
/**
* 判断当前登录用户是否允许提交会员个性化设置。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取会员个性化设置的验证规则。
*/
public function rules(): array
{
return [
'custom_join_message' => ['nullable', 'string', 'max:255'],
'custom_leave_message' => ['nullable', 'string', 'max:255'],
];
}
/**
* 获取会员个性化设置的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'custom_join_message.max' => '欢迎语最多只能填写 255 个字符。',
'custom_leave_message.max' => '离开语最多只能填写 255 个字符。',
];
}
}

View File

@@ -1,5 +1,10 @@
<?php
/**
* 文件功能:用户离开聊天室后的异步清理与播报任务
* 负责清理在线状态、关闭勤务日志,并根据会员/管理员身份发送不同的离场提示。
*/
namespace App\Jobs;
use App\Models\PositionDutyLog;
@@ -19,12 +24,18 @@ class ProcessUserLeave implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 构造离场处理任务实例。
*/
public function __construct(
public int $roomId,
public User $user,
public float $leaveTime
) {}
/**
* 执行离场任务:清理在线状态并广播离场消息。
*/
public function handle(ChatStateService $chatState, RoomBroadcastService $broadcast): void
{
// 获取该用户最后一次进入房间的时间
@@ -66,6 +77,7 @@ class ProcessUserLeave implements ShouldQueue
];
} else {
[$leaveText, $color] = $broadcast->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' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'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();
}
}
/**

View File

@@ -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 购买订单记录
*/

View File

@@ -20,6 +20,32 @@ class VipLevel extends Model
{
use HasFactory;
/**
* 会员进退场支持的特效选项。
*
* @var array<int, string>
*/
public const EFFECT_OPTIONS = [
'none',
'fireworks',
'rain',
'lightning',
'snow',
];
/**
* 会员进退场支持的横幅风格选项。
*
* @var array<int, string>
*/
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';
}
}

View File

@@ -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<string, string|null>
*/
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'] ?? ''),
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:会员进退场主题服务
* 统一解析会员等级主题、用户自定义文案、特效和横幅风格,
* 避免聊天室进场与离场逻辑在多个位置重复拼装数据。
*/
namespace App\Services;
use App\Models\User;
class VipPresenceService
{
/**
* 构造会员进退场主题服务实例。
*/
public function __construct(
private readonly VipService $vipService,
) {}
/**
* 构建会员入场主题数据。
*
* @return array<string, string|null|bool>
*/
public function buildJoinTheme(User $user): array
{
return $this->buildTheme($user, 'join');
}
/**
* 构建会员离场主题数据。
*
* @return array<string, string|null|bool>
*/
public function buildLeaveTheme(User $user): array
{
return $this->buildTheme($user, 'leave');
}
/**
* 统一构建会员进场或离场的主题数据。
*
* @param string $type join|leave
* @return array<string, string|null|bool>
*/
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;
}
}

View File

@@ -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,
];
}

View File

@@ -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,

View File

@@ -0,0 +1,138 @@
<?php
/**
* 文件功能:为 VIP 等级与用户增加进退场主题字段
* 支持会员等级专属特效、横幅风格以及用户自定义欢迎语和离开语。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移:为 vip_levels users 表增加会员进退场主题相关字段。
*/
public function up(): void
{
Schema::table('vip_levels', function (Blueprint $table) {
$table->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);
}
}
};

View File

@@ -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;
}

View File

@@ -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%);
}
}

View File

@@ -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 @@
<span class="text-gray-500">当前会员</span>
<span class="font-bold text-indigo-600">{{ $level->users()->count() }} </span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">进场特效</span>
<span class="font-bold text-sky-600">{{ $effectOptions[$level->joinEffectKey()] ?? '无特效' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">离场特效</span>
<span class="font-bold text-violet-600">{{ $effectOptions[$level->leaveEffectKey()] ?? '无特效' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">允许自定义</span>
<span class="font-bold {{ $level->allow_custom_messages ? 'text-emerald-600' : 'text-gray-400' }}">{{ $level->allow_custom_messages ? '允许' : '关闭' }}</span>
</div>
</div>
</div>
@@ -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"></textarea>
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">入场特效</label>
<select name="join_effect" x-model="form.join_effect" class="w-full border rounded-md p-2 text-sm">
@foreach ($effectOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">离场特效</label>
<select name="leave_effect" x-model="form.leave_effect" class="w-full border rounded-md p-2 text-sm">
@foreach ($effectOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">入场横幅风格</label>
<select name="join_banner_style" x-model="form.join_banner_style" class="w-full border rounded-md p-2 text-sm">
@foreach ($bannerStyleOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">离场横幅风格</label>
<select name="leave_banner_style" x-model="form.leave_banner_style" class="w-full border rounded-md p-2 text-sm">
@foreach ($bannerStyleOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<label class="mt-4 flex items-center gap-3 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<input type="checkbox" name="allow_custom_messages" value="1" x-model="form.allow_custom_messages"
class="rounded border-amber-300 text-amber-600 focus:ring-amber-400">
允许该会员等级用户在会员中心自定义欢迎语和离开语
</label>
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
<button type="button" @click="showForm = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>

View File

@@ -232,16 +232,18 @@
});
</script>
@endif
@if (!empty($newbieEffect) || !empty($weekEffect))
@if (!empty($newbieEffect) || !empty($weekEffect) || !empty($initialPresenceTheme['presence_effect']))
<script>
/**
* 延迟1秒待页面完成初始化后自动播放进房附带的特效
* 优先级:如果有新人礼包特效,优先播放新人大礼包;如果没有,再播放周卡特效
* 延迟1秒待页面完成初始化后自动播放进房附带的特效
* 优先级:新人礼包特效 -> 会员专属进场特效 -> 周卡特效
*/
setTimeout(() => {
if (typeof EffectManager !== 'undefined') {
@if (!empty($newbieEffect))
EffectManager.play('{{ $newbieEffect }}');
@elseif (!empty($initialPresenceTheme['presence_effect']))
EffectManager.play('{{ $initialPresenceTheme['presence_effect'] }}');
@elseif (!empty($weekEffect))
EffectManager.play('{{ $weekEffect }}');
@endif
@@ -249,6 +251,17 @@
}, 1000);
</script>
@endif
@if (!empty($initialPresenceTheme))
<script>
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (typeof window.showVipPresenceBanner === 'function') {
window.showVipPresenceBanner(@json($initialPresenceTheme));
}
}, 700);
});
</script>
@endif
{{-- 页面初始加载时,若存在挂起的求婚 / 离婚请求,则弹窗 --}}
@if (!empty($pendingProposal) || !empty($pendingDivorce))

View File

@@ -68,6 +68,89 @@
let autoScroll = true;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
/**
* 转义会员横幅文本,避免横幅层被注入 HTML。
*/
function escapePresenceText(text) {
return escapeHtml(String(text ?? '')).replace(/\n/g, '<br>');
}
/**
* 根据不同的会员横幅风格返回渐变与光影配置。
*/
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 = `
<div class="vip-presence-banner__glow" style="background:${styleConfig.glow};"></div>
<div class="vip-presence-banner__card" style="background:${styleConfig.gradient}; border-color:${payload.presence_color || '#fff'};">
<div class="vip-presence-banner__meta">
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || '👑')}</span>
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || '尊贵会员')}</span>
<span class="vip-presence-banner__type">${payload.presence_type === 'leave' ? '离场提示' : '闪耀登场'}</span>
</div>
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">${escapePresenceText(payload.presence_text)}</div>
</div>
`;
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 = `
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:42px;height:42px;border-radius:14px;background:radial-gradient(circle at top, ${accent}, #111827);display:flex;align-items:center;justify-content:center;font-size:22px;box-shadow:0 0 22px ${accent}55;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:12px;font-weight:800;letter-spacing:.08em;color:${accent};text-transform:uppercase;">${typeLabel}</span>
<span style="font-size:12px;color:#e2e8f0;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:14px;line-height:1.6;color:#f8fafc;">${safeText}</div>
</div>
</div>
`;
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;

View File

@@ -139,6 +139,118 @@
</div>
</section>
<section class="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden">
<div class="bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.24),_transparent_42%),linear-gradient(135deg,#0f172a,#1e293b,#334155)] px-6 py-6 text-white">
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-[0.28em] text-amber-300/90">会员进退场主题</p>
<h3 class="mt-2 text-2xl font-black">欢迎语、离开语与专属入场仪式</h3>
<p class="mt-2 max-w-2xl text-sm text-slate-200">
这里可以查看当前会员档位的专属特效和横幅风格;若当前档位允许自定义,你还可以设置自己的欢迎语和离开语。
</p>
</div>
@if ($user->isVip())
<div class="rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-sm text-slate-100">
当前档位:<span class="font-extrabold" style="color: {{ $user->vipLevel?->color ?: '#fef3c7' }}">{{ $user->vipName() }}</span>
</div>
@endif
</div>
</div>
<div class="grid grid-cols-1 gap-6 p-6 lg:grid-cols-[1.2fr,0.8fr]">
<div class="space-y-5">
<div class="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-2xl text-white shadow-lg">
{{ $user->vipLevel?->icon ?: '✨' }}
</div>
<div>
<p class="text-sm font-bold text-slate-500">当前主题预览</p>
<h4 class="text-xl font-black text-slate-900">{{ $user->vipLevel?->name ?? '普通用户' }}</h4>
</div>
</div>
<div class="mt-5 grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">入场特效</p>
<p class="mt-2 text-lg font-extrabold text-slate-900">{{ $effectOptions[$user->vipLevel?->joinEffectKey() ?? 'none'] ?? '无特效' }}</p>
<p class="mt-2 text-sm text-slate-500">横幅风格:{{ $bannerStyleOptions[$user->vipLevel?->joinBannerStyleKey() ?? 'aurora'] ?? '鎏光星幕' }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">离场特效</p>
<p class="mt-2 text-lg font-extrabold text-slate-900">{{ $effectOptions[$user->vipLevel?->leaveEffectKey() ?? 'none'] ?? '无特效' }}</p>
<p class="mt-2 text-sm text-slate-500">横幅风格:{{ $bannerStyleOptions[$user->vipLevel?->leaveBannerStyleKey() ?? 'farewell'] ?? '告别暮光' }}</p>
</div>
</div>
</div>
<div class="rounded-3xl border border-amber-100 bg-amber-50/70 p-5">
<p class="text-sm font-bold text-amber-700">等级默认语句</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-amber-500">默认欢迎语</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ $user->vipLevel?->join_templates_array[0] ?? '当前档位尚未配置默认欢迎语。' }}
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-amber-500">默认离开语</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ $user->vipLevel?->leave_templates_array[0] ?? '当前档位尚未配置默认离开语。' }}
</p>
</div>
</div>
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-bold text-slate-500">我的个性化设置</p>
<h4 class="mt-1 text-xl font-black text-slate-900">自定义欢迎语与离开语</h4>
</div>
@if ($user->canCustomizeVipPresence())
<span class="inline-flex rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700">已开启</span>
@else
<span class="inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs font-bold text-gray-500">未开放</span>
@endif
</div>
@if ($user->canCustomizeVipPresence())
<form action="{{ route('vip.center.presence.update') }}" method="POST" class="mt-5 space-y-4">
@csrf
@method('PUT')
<div>
<label class="mb-2 block text-sm font-bold text-slate-700">我的欢迎语</label>
<textarea name="custom_join_message" rows="4" maxlength="255"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-amber-400 focus:bg-white"
placeholder="例:{username} 乘着星舰闪耀登场,今晚全场高光属于 TA">{{ old('custom_join_message', $user->custom_join_message) }}</textarea>
<p class="mt-2 text-xs text-slate-500">支持使用 <span class="font-mono text-slate-700">{username}</span> 占位符自动替换成你的昵称。</p>
</div>
<div>
<label class="mb-2 block text-sm font-bold text-slate-700">我的离开语</label>
<textarea name="custom_leave_message" rows="4" maxlength="255"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-amber-400 focus:bg-white"
placeholder="例:{username} 留下一道流光背影,优雅谢幕,我们下次再见。">{{ old('custom_leave_message', $user->custom_leave_message) }}</textarea>
</div>
<button type="submit"
class="w-full rounded-2xl bg-slate-900 px-5 py-3 text-sm font-bold text-white transition hover:bg-slate-800">
保存我的专属语句
</button>
</form>
@elseif ($user->isVip())
<div class="mt-5 rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-5 text-sm leading-7 text-slate-500">
当前会员档位暂未开放个人自定义功能,不过你仍会自动使用本等级配置的专属欢迎语、离开语和华丽特效。
</div>
@else
<div class="mt-5 rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-5 text-sm leading-7 text-amber-800">
开通会员后,这里会解锁对应等级的专属进退场主题;若等级允许,还能设置你自己的欢迎语和离开语。
</div>
@endif
</div>
</div>
</section>
<section class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between gap-4 mb-5">
<div>

View File

@@ -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');

View File

@@ -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();

View File

@@ -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' => '不应被保存',
]);
}
/**
* 写入测试所需的支付中心配置
*/