Add VIP presence themes and custom greetings
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Http/Requests/UpdateVipPresenceSettingsRequest.php
Normal file
45
app/Http/Requests/UpdateVipPresenceSettingsRequest.php
Normal 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 个字符。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 购买订单记录
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
112
app/Services/VipPresenceService.php
Normal file
112
app/Services/VipPresenceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' => '不应被保存',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入测试所需的支付中心配置
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user