From ffccfa26e9c10833f389258d5a4ed5f886d3c45c Mon Sep 17 00:00:00 2001 From: lkddi Date: Mon, 27 Apr 2026 11:12:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=95=86=E5=BA=97=E4=B8=AA?= =?UTF-8?q?=E6=80=A7=E8=A3=85=E6=89=AE=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Enums/CurrencySource.php | 8 + app/Http/Controllers/ChatController.php | 4 + app/Http/Controllers/ShopController.php | 21 ++ app/Models/User.php | 1 + app/Services/ChatUserPresenceService.php | 9 + app/Services/DecorationService.php | 220 ++++++++++++++ app/Services/ShopService.php | 11 + ..._add_active_decorations_to_users_table.php | 45 +++ ...d_decoration_types_to_shop_items_table.php | 39 +++ database/seeders/ShopItemSeeder.php | 51 +++- resources/js/chat-room/shop-controls.js | 216 +++++++++++++- .../chat/partials/layout/toolbar.blade.php | 76 +++++ .../views/chat/partials/scripts.blade.php | 280 +++++++++++++++++- .../views/chat/partials/shop-panel.blade.php | 66 ++++- 14 files changed, 1027 insertions(+), 20 deletions(-) create mode 100644 app/Services/DecorationService.php create mode 100644 database/migrations/2026_04_27_000000_add_active_decorations_to_users_table.php create mode 100644 database/migrations/2026_04_27_000001_add_decoration_types_to_shop_items_table.php diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 0ff2f02..393cebf 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -143,6 +143,12 @@ enum CurrencySource: string /** 查看别人隐藏信息扣费 */ case USER_INFO_REVEAL = 'user_info_reveal'; + /** 购买消息装扮消耗(气泡/昵称颜色,扣除金币) */ + case MSG_DECORATION_BUY = 'msg_decoration_buy'; + + /** 购买头像框消耗(扣除金币) */ + case AVATAR_FRAME_BUY = 'avatar_frame_buy'; + /** * 返回该来源的中文名称,用于后台统计展示。 */ @@ -190,6 +196,8 @@ enum CurrencySource: string self::GOMOKU_REFUND => '五子棋入场费返还', self::VIDEO_REWARD => '看视频奖励', self::USER_INFO_REVEAL => '信息查看付费', + self::MSG_DECORATION_BUY => '消息装扮购买', + self::AVATAR_FRAME_BUY => '头像框购买', }; } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 5f9f7df..cf6889f 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -413,6 +413,10 @@ class ChatController extends Controller $messageData = array_merge($messageData, $imagePayload); } + // 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观 + $decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user); + $messageData = array_merge($messageData, $decorations); + // 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条) $this->chatState->pushMessage($id, $messageData); diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 105c8b1..063cdce 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -9,10 +9,12 @@ namespace App\Http\Controllers; use App\Events\EffectBroadcast; use App\Events\MessageSent; +use App\Events\UserStatusUpdated; use App\Models\Room; use App\Models\ShopItem; use App\Models\UserPurchase; use App\Services\ChatStateService; +use App\Services\DecorationService; use App\Services\ShopService; use App\Support\ChatContentSanitizer; use Illuminate\Http\JsonResponse; @@ -73,6 +75,8 @@ class ShopController extends Controller 'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user), 'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user), 'sign_repair_card_item' => $signRepairCard, + // 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签 + 'active_decorations' => app(DecorationService::class)->getActiveDecorations($user), ]); } @@ -121,6 +125,22 @@ class ShopController extends Controller 'total_price' => $result['total_price'] ?? $item->price, ]; + // ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ── + if (! empty($result['slot'])) { + $response['slot'] = $result['slot']; + $response['style'] = $result['style']; + $response['expires_at'] = $result['expires_at']; + + // 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效 + $freshUser = $user->fresh(); + $presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser); + foreach ($this->chatState->getUserRooms($user->username) as $rid) { + $this->chatState->userJoin((int) $rid, $user->username, $presenceData); + // 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表 + broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData)); + } + } + // ── 单次特效卡:广播给指定用户或全员 ──────────────────────── if (isset($result['play_effect'])) { $recipient = trim($request->input('recipient', '')); // 空字符串 = 全员 @@ -205,6 +225,7 @@ class ShopController extends Controller 'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?", 'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!", ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!", + 'msg_bubble', 'msg_name_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!", default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。", }; diff --git a/app/Models/User.php b/app/Models/User.php index 648e285..7ed6503 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -109,6 +109,7 @@ class User extends Authenticatable 'q3_time' => 'datetime', 'has_received_new_gift' => 'boolean', 'chat_preferences' => 'array', + 'active_decorations' => 'array', 'daily_status_expires_at' => 'datetime', ]; } diff --git a/app/Services/ChatUserPresenceService.php b/app/Services/ChatUserPresenceService.php index fc1115a..3478eee 100644 --- a/app/Services/ChatUserPresenceService.php +++ b/app/Services/ChatUserPresenceService.php @@ -64,6 +64,15 @@ class ChatUserPresenceService $payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0); } + // 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果 + $decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user); + if (! empty($decorations['avatar_frame'])) { + $payload['avatar_frame'] = $decorations['avatar_frame']; + } + if (! empty($decorations['name_color'])) { + $payload['name_color'] = $decorations['name_color']; + } + return $payload; } diff --git a/app/Services/DecorationService.php b/app/Services/DecorationService.php new file mode 100644 index 0000000..7e7a3da --- /dev/null +++ b/app/Services/DecorationService.php @@ -0,0 +1,220 @@ + 'bubble', + 'msg_name_color' => 'name_color', + 'avatar_frame' => 'avatar_frame', + ]; + + /** + * @param UserCurrencyService $currencyService 统一积分变更服务 + */ + public function __construct( + private readonly UserCurrencyService $currencyService, + ) {} + + /** + * 购买装扮:扣金币、写购买记录、更新 users.active_decorations。 + * + * 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。 + * + * @param User $user 购买用户 + * @param ShopItem $item 装扮商品 + * @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string} + */ + public function purchase(User $user, ShopItem $item): array + { + // 根据商品类型映射到对应槽位 + $slot = self::TYPE_TO_SLOT[$item->type] ?? null; + if (! $slot) { + return ['ok' => false, 'message' => '未知装扮类型']; + } + + // 校验金币余额 + if ($user->jjb < $item->price) { + return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"]; + } + + // 计算过期时间(至少 1 天) + $days = max(1, (int) ($item->duration_days ?? 1)); + $expiresAt = Carbon::now()->addDays($days); + + // 头像框与其他装扮使用不同的流水来源标识 + $source = $item->type === 'avatar_frame' + ? CurrencySource::AVATAR_FRAME_BUY + : CurrencySource::MSG_DECORATION_BUY; + + // 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作 + DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) { + // ① 通过统一积分服务扣除金币(含流水记录) + $this->currencyService->change( + $user, 'gold', -$item->price, $source, + "购买装扮:{$item->name}({$days}天)" + ); + + // ② 写入购买记录(用于后台统计与用户回溯) + UserPurchase::create([ + 'user_id' => $user->id, + 'shop_item_id' => $item->id, + 'status' => 'active', + 'price_paid' => $item->price, + 'expires_at' => $expiresAt, + ]); + + // ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并) + $decorations = $this->getActiveDecorations($user); + $decorations[$slot] = [ + 'style' => $item->slug, + 'expires_at' => $expiresAt->toIso8601String(), + ]; + $user->active_decorations = $decorations; + $user->save(); + }); + + // 重新读取最新余额,避免缓存脏数据 + $balanceAfter = (int) $user->fresh()->jjb; + + return [ + 'ok' => true, + 'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)", + 'balance_after' => $balanceAfter, + 'slot' => $slot, + 'style' => $item->slug, + 'expires_at' => $expiresAt->toIso8601String(), + ]; + } + + /** + * 获取用户当前所有激活的装扮,同时自动清理已过期项。 + * + * 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。 + * 如果清理了过期数据会自动写回 users.active_decorations。 + * + * @param User $user 目标用户 + * @return array 返回干净的装扮列表 + */ + public function getActiveDecorations(User $user): array + { + $decorations = $user->active_decorations ?? []; + + // 非数组(null 或格式异常)直接返回空 + if (! is_array($decorations)) { + return []; + } + + $now = Carbon::now(); + $changed = false; + + foreach ($decorations as $slot => $data) { + // 格式校验:必须包含 expires_at 字段 + if (! is_array($data) || empty($data['expires_at'])) { + unset($decorations[$slot]); + $changed = true; + + continue; + } + + // 解析过期时间并与当前时间比较 + try { + $expiresAt = Carbon::parse($data['expires_at']); + if ($expiresAt->isPast()) { + unset($decorations[$slot]); + $changed = true; + } + } catch (\Exception $e) { + // 时间格式异常也视为无效,清理之 + unset($decorations[$slot]); + $changed = true; + } + } + + // 有过期项被清理时写回数据库 + if ($changed) { + $user->active_decorations = $decorations; + $user->save(); + } + + return $decorations; + } + + /** + * 获取消息广播 payload 需要携带的装扮字段。 + * + * 消息广播只需要气泡样式(msg_bubble)和昵称颜色(msg_name_color), + * 头像框不需要在消息中展示。 + * + * @param User $user 消息发送者 + * @return array{msg_bubble?:string, msg_name_color?:string} + */ + public function getDecorationsForMessage(User $user): array + { + $decorations = $this->getActiveDecorations($user); + $result = []; + + if (! empty($decorations['bubble']['style'])) { + $result['msg_bubble'] = $decorations['bubble']['style']; + } + if (! empty($decorations['name_color']['style'])) { + $result['msg_name_color'] = $decorations['name_color']['style']; + } + + return $result; + } + + /** + * 获取在线用户 Presence 载荷需要携带的装扮字段。 + * + * 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color), + * 气泡样式仅作用于消息,不需要在用户列表中展示。 + * + * @param User $user 目标用户 + * @return array{avatar_frame?:string, name_color?:string} + */ + public function getDecorationsForPresence(User $user): array + { + $decorations = $this->getActiveDecorations($user); + $result = []; + + if (! empty($decorations['avatar_frame']['style'])) { + $result['avatar_frame'] = $decorations['avatar_frame']['style']; + } + if (! empty($decorations['name_color']['style'])) { + $result['name_color'] = $decorations['name_color']['style']; + } + + return $result; + } +} diff --git a/app/Services/ShopService.php b/app/Services/ShopService.php index 33afb87..0b57525 100644 --- a/app/Services/ShopService.php +++ b/app/Services/ShopService.php @@ -16,6 +16,13 @@ use Illuminate\Support\Facades\DB; class ShopService { + /** + * @param DecorationService $decorationService 装扮服务 + */ + public function __construct( + private readonly DecorationService $decorationService, + ) {} + /** * 购买商品入口:扣金币、按类型分发处理 * @@ -41,6 +48,10 @@ class ShopService 'ring' => $this->buyRing($user, $item), 'auto_fishing' => $this->buyAutoFishingCard($user, $item), ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity), + // ── 个人装扮购买(委托给 DecorationService)─────────────── + 'msg_bubble' => $this->decorationService->purchase($user, $item), + 'msg_name_color' => $this->decorationService->purchase($user, $item), + 'avatar_frame' => $this->decorationService->purchase($user, $item), default => ['ok' => false, 'message' => '未知商品类型'], }; } diff --git a/database/migrations/2026_04_27_000000_add_active_decorations_to_users_table.php b/database/migrations/2026_04_27_000000_add_active_decorations_to_users_table.php new file mode 100644 index 0000000..f42ea07 --- /dev/null +++ b/database/migrations/2026_04_27_000000_add_active_decorations_to_users_table.php @@ -0,0 +1,45 @@ +json('active_decorations')->nullable() + ->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间'); + }); + } + + /** + * 回滚:删除 active_decorations 列。 + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('active_decorations'); + }); + } +}; diff --git a/database/migrations/2026_04_27_000001_add_decoration_types_to_shop_items_table.php b/database/migrations/2026_04_27_000001_add_decoration_types_to_shop_items_table.php new file mode 100644 index 0000000..7006d04 --- /dev/null +++ b/database/migrations/2026_04_27_000001_add_decoration_types_to_shop_items_table.php @@ -0,0 +1,39 @@ + '改名卡', 'slug' => 'rename_card', 'icon' => '🎭', 'description' => '使用后可修改一次昵称(旧名保留30天黑名单,不可被他人注册)。注意:历史聊天记录中的旧名不会更改。', 'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 30], + + // ── 消息气泡装扮 ────────────────────────── + ['name' => '鎏金流光气泡', 'slug' => 'msg_bubble_golden', 'icon' => '🟡', + 'description' => '浅金底纹、左侧金线和流光扫过,发言更醒目但不刺眼。', + 'price' => 300, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 50], + ['name' => '樱语花笺气泡', 'slug' => 'msg_bubble_sakura', 'icon' => '🌸', + 'description' => '粉白信笺底色配花点纹理,适合温柔、浪漫的发言氛围。', + 'price' => 500, 'type' => 'msg_bubble', 'duration_days' => 3, 'sort_order' => 51], + ['name' => '星河微光气泡', 'slug' => 'msg_bubble_star', 'icon' => '🌌', + 'description' => '淡蓝星河底纹和微光星点,保留清爽阅读感。', + 'price' => 800, 'type' => 'msg_bubble', 'duration_days' => 7, 'sort_order' => 52], + ['name' => '霓虹彩带气泡', 'slug' => 'msg_bubble_rainbow', 'icon' => '🌈', + 'description' => '顶部流动彩带和浅色底框,发言有动态高光。', + 'price' => 1500, 'type' => 'msg_bubble', 'duration_days' => 7, 'sort_order' => 53], + ['name' => '皇冠礼赞气泡', 'slug' => 'msg_bubble_crown', 'icon' => '👑', + 'description' => '金色礼赞底纹、皇冠角标和侧边金线,突出尊贵发言。', + 'price' => 3000, 'type' => 'msg_bubble', 'duration_days' => 30, 'sort_order' => 54], + + // ── 昵称颜色装扮 ────────────────────────── + ['name' => '金色昵称', 'slug' => 'msg_name_golden', 'icon' => '🥇', + 'description' => '让你的昵称闪耀金光。', + 'price' => 200, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 60], + ['name' => '渐变昵称', 'slug' => 'msg_name_rainbow', 'icon' => '🎨', + 'description' => '彩虹渐变色昵称,五彩斑斓。', + 'price' => 500, 'type' => 'msg_name_color', 'duration_days' => 3, 'sort_order' => 61], + ['name' => '发光昵称', 'slug' => 'msg_name_glow', 'icon' => '✨', + 'description' => '昵称带柔和发光效果,暗夜中最亮的星。', + 'price' => 800, 'type' => 'msg_name_color', 'duration_days' => 7, 'sort_order' => 62], + ['name' => '火焰昵称', 'slug' => 'msg_name_flame', 'icon' => '🔥', + 'description' => '火焰色脉动昵称,热情似火。', + 'price' => 1500, 'type' => 'msg_name_color', 'duration_days' => 7, 'sort_order' => 63], + + // ── 头像框装扮 ──────────────────────────── + ['name' => '月银守护头像框', 'slug' => 'avatar_frame_silver', 'icon' => '🥈', + 'description' => '银白金属光泽外框,低调但比普通头像更精致。', + 'price' => 500, 'type' => 'avatar_frame', 'duration_days' => 7, 'sort_order' => 70], + ['name' => '金辉勋章头像框', 'slug' => 'avatar_frame_gold', 'icon' => '🥇', + 'description' => '金色勋章质感外框,带柔和光晕。', + 'price' => 1000, 'type' => 'avatar_frame', 'duration_days' => 7, 'sort_order' => 71], + ['name' => '星轨环绕头像框', 'slug' => 'avatar_frame_star', 'icon' => '⭐', + 'description' => '星轨渐变环绕头像旋转,适合高调展示。', + 'price' => 2000, 'type' => 'avatar_frame', 'duration_days' => 14, 'sort_order' => 72], + ['name' => '龙焰御守头像框', 'slug' => 'avatar_frame_dragon', 'icon' => '🐉', + 'description' => '红金御守质感外框,带虚线纹理和强烈光晕。', + 'price' => 5000, 'type' => 'avatar_frame', 'duration_days' => 30, 'sort_order' => 73], ]; foreach ($items as $item) { - ShopItem::firstOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]); + // 商品名和描述可能随视觉样式迭代,Seeder 重跑时需要同步更新展示文案。 + ShopItem::updateOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]); } } } diff --git a/resources/js/chat-room/shop-controls.js b/resources/js/chat-room/shop-controls.js index 8ec1f9a..bf6ad75 100644 --- a/resources/js/chat-room/shop-controls.js +++ b/resources/js/chat-room/shop-controls.js @@ -47,9 +47,34 @@ const SHOP_GROUPS = [ }, ]; +const DECORATION_GROUPS = [ + { + label: "💬 消息气泡", + desc: "同类型只保留最新购买", + type: "msg_bubble", + }, + { + label: "🎨 昵称颜色", + desc: "同类型只保留最新购买", + type: "msg_name_color", + }, + { + label: "🖼️ 头像框", + desc: "同类型只保留最新购买", + type: "avatar_frame", + }, +]; + +const DECORATION_TYPE_TO_SLOT = { + msg_bubble: "bubble", + msg_name_color: "name_color", + avatar_frame: "avatar_frame", +}; + let shopControlEventsBound = false; let shopLoaded = false; let giftItem = null; +let activeDecorations = {}; /** * 读取商店根节点上由 Blade 注入的接口地址。 @@ -106,6 +131,7 @@ export function openShopModal() { } modal.style.display = "flex"; + bindShopTabs(); if (!shopLoaded) { shopLoaded = true; fetchShopData(); @@ -124,6 +150,45 @@ export function closeShopModal() { } } +/** + * 绑定商店 Tab 切换逻辑。 + * + * @returns {void} + */ +function bindShopTabs() { + const tabsContainer = document.getElementById("shop-tabs"); + if (!tabsContainer || tabsContainer.dataset.shopTabsBound) { + return; + } + tabsContainer.dataset.shopTabsBound = "1"; + + tabsContainer.addEventListener("click", (event) => { + const tab = event.target.closest("[data-shop-tab]"); + if (!tab) { + return; + } + + const tabName = tab.dataset.shopTab; + const itemsList = document.getElementById("shop-items-list"); + const decorationsList = document.getElementById("shop-decorations-list"); + + // 切换当前 Tab 的选中状态。 + tabsContainer.querySelectorAll(".shop-tab").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.shopTab === tabName); + }); + + // 切换对应商品列表,装扮列表需要显式恢复 grid 布局。 + if (tabName === "items") { + if (itemsList) itemsList.style.display = ""; + if (decorationsList) decorationsList.style.display = "none"; + } else { + if (itemsList) itemsList.style.display = "none"; + // 装扮列表在 CSS 中为 display:none,需显式设置为 grid 才能覆盖 + if (decorationsList) decorationsList.style.display = "grid"; + } + }); +} + /** * 拉取商品数据并渲染列表。 * @@ -136,6 +201,7 @@ export async function fetchShopData() { }); const data = await response.json(); renderShop(data); + renderDecorations(data); } catch (error) { showShopToast("⚠ 加载失败,请重试", false); } @@ -245,6 +311,90 @@ export function renderShop(data) { }); } +/** + * 渲染个人装扮商品列表。 + * + * @param {Record} data 商店接口数据 + * @returns {void} + */ +export function renderDecorations(data) { + const list = document.getElementById("shop-decorations-list"); + if (!list) { + return; + } + + // 记录当前激活装扮,渲染时只给当前款式显示"已激活"。 + activeDecorations = data.active_decorations || {}; + + const items = Array.isArray(data.items) ? data.items : []; + list.innerHTML = ""; + + // 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。 + const note = document.createElement("div"); + note.className = "decoration-note"; + note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。"; + list.appendChild(note); + + DECORATION_GROUPS.forEach((group) => { + const groupItems = items.filter((item) => item.type === group.type); + if (!groupItems.length) { + return; + } + + // 分组标题(独占一整行),描述文字内嵌到标题中避免单独占一格 + const header = document.createElement("div"); + header.className = "shop-group-header"; + header.innerHTML = `${escapeHtml(group.label)}${group.desc ? ` ${escapeHtml(group.desc)}` : ''}`; + list.appendChild(header); + + groupItems.forEach((item) => { + const card = document.createElement("div"); + card.className = "shop-card"; + + // active_decorations 由后端按槽位名索引,先把商品 type 映射到对应槽位。 + const slot = DECORATION_TYPE_TO_SLOT[item.type] || null; + const activeDeco = slot ? (activeDecorations[slot] || null) : null; + const isActiveStyle = activeDeco && activeDeco.style === item.slug; + const expiresAt = activeDeco ? activeDeco.expires_at : null; + let daysLeft = ""; + if (isActiveStyle && expiresAt) { + const remaining = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))); + daysLeft = remaining > 0 ? `剩余 ${remaining} 天` : "即将过期"; + } + + const button = document.createElement("button"); + if (isActiveStyle) { + button.className = "shop-btn"; + button.textContent = "续费 💰 " + Number(item.price || 0).toLocaleString(); + } else { + button.className = "shop-btn"; + button.textContent = "购买 💰 " + Number(item.price || 0).toLocaleString(); + } + button.addEventListener("click", () => confirmAndBuyItem(item)); + + // 仅当前已激活的款式显示状态标签,同槽位其他款式保持普通购买状态。 + const statusHtml = isActiveStyle + ? `已激活${daysLeft ? ' · ' + daysLeft : ''}` + : ""; + + const validityHtml = buildValidityHtml(item); + + card.innerHTML = ` +
+ ${escapeHtml(item.icon)} + ${escapeHtml(item.name)} +
+ ${statusHtml ? `
${statusHtml}
` : ""} +
${escapeHtml(item.description ?? "")}
+ ${validityHtml} + ${isActiveStyle && expiresAt ? `
⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}
` : ""} + `; + card.appendChild(button); + list.appendChild(card); + }); + }); +} + /** * 创建单个商品卡片,并挂载购买或使用按钮事件。 * @@ -302,9 +452,7 @@ function buildShopCardHtml(item, options) { ${options.ownedQty} ` : `${escapeHtml(item.icon)}`; - const durationLabel = options.isAutoFishing && Number(item.duration_minutes || 0) > 0 - ? `
⏱ 有效期 ${escapeHtml(formatMinutes(item.duration_minutes))}
` - : ""; + const validityHtml = buildValidityHtml(item); const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0) ? `
${Number(item.intimacy_bonus || 0) > 0 ? `💞 亲密 +${Number(item.intimacy_bonus || 0)}` : ""} @@ -319,10 +467,52 @@ function buildShopCardHtml(item, options) {
${escapeHtml(item.description ?? "")}
${ringBonus} - ${durationLabel} + ${validityHtml} `; } +/** + * 生成购买前展示的有效期或生效方式文案。 + * + * @param {Record} item 商品数据 + * @returns {string} + */ +function buildValidityText(item) { + if (Number(item.duration_days || 0) > 0) { + return `有效期:${Number(item.duration_days)} 天`; + } + + if (Number(item.duration_minutes || 0) > 0) { + return `有效期:${formatMinutes(item.duration_minutes)}`; + } + + if (item.type === "instant") { + return "购买后立即播放 1 次"; + } + + if (item.type === "ring") { + return "购买后存入背包,求婚时消耗"; + } + + if (["one_time", "sign_repair"].includes(item.type)) { + return "购买后存入背包,使用时消耗"; + } + + return ""; +} + +/** + * 生成商品卡片里的有效期标签 HTML。 + * + * @param {Record} item 商品数据 + * @returns {string} + */ +function buildValidityHtml(item) { + const text = buildValidityText(item); + + return text ? `
⏱ ${escapeHtml(text)}
` : ""; +} + /** * 格式化分钟数,供自动钓鱼卡有效期展示。 * @@ -351,7 +541,11 @@ async function confirmAndBuyItem(item) { } } - const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""} 吗?`; + const validityText = buildValidityText(item); + const replacementText = DECORATION_TYPE_TO_SLOT[item.type] + ? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。" + : ""; + const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`; const confirmed = await confirmShopPurchase(confirmMessage); if (confirmed) { @@ -401,7 +595,8 @@ export function openGiftDialog(item) { } if (itemName) { - itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()})`; + const validityText = buildValidityText(item); + itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""})`; } if (dialog) { @@ -499,6 +694,14 @@ function handleBuySuccess(data, itemName) { showShopToast(`✅ ${itemName} 购买成功!`, true); + // 装扮购买成功后先更新本地缓存,随后再拉接口刷新完整状态。 + if (data.slot && data.style) { + activeDecorations[data.slot] = { + style: data.style, + expires_at: data.expires_at, + }; + } + // 购买者本地也要立即看到特效,广播只负责其他在线用户。 if (data.play_effect && window.EffectManager) { window.EffectManager.play(data.play_effect); @@ -625,6 +828,7 @@ function exposeShopGlobals() { window.loadShop = loadShop; window.fetchShopData = fetchShopData; window.renderShop = renderShop; + window.renderDecorations = renderDecorations; window.openGiftDialog = openGiftDialog; window.closeGiftDialog = closeGiftDialog; window.confirmGift = confirmGift; diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 3f869b4..59ab784 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -341,6 +341,40 @@ background: #f6faff; } + /* 装扮列表区 — 与商品列表同风格 */ + #shop-decorations-list { + flex: 1; + overflow-y: auto; + padding: 10px 12px; + display: none; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + align-content: start; + background: #f6faff; + } + + /* Tab 导航 */ + #shop-tabs { + display: flex; + border-bottom: 1px solid #cde; + flex-shrink: 0; + background: #eef4fb; + } + .shop-tab { + flex: 1; + padding: 8px 6px; + background: transparent; + border: none; + color: #6b7280; + font-size: 12px; + cursor: pointer; + border-bottom: 2px solid transparent; + font-weight: bold; + transition: all .2s; + } + .shop-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; } + .shop-tab:hover { color: #5a8fc0; } + /* 分组标题 — 独占一整行 */ .shop-group-header { grid-column: 1 / -1; @@ -404,6 +438,37 @@ flex: 1; } + /* 装扮Tab购买说明 */ + .decoration-note { + font-size: 11px; + color: #b45309; + background: #fef3c7; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 6px 10px; + margin-bottom: 10px; + grid-column: 1 / -1; + } + /* 装扮卡片状态行(独立一行,显示在商品名下方) */ + .decoration-status-line { + margin-top: 4px; + } + /* 装扮卡片状态标签 */ + .decoration-status { + font-size: 9px; + padding: 1px 6px; + border-radius: 8px; + } + .decoration-status.active { background: #065f46; color: #6ee7b7; } + .decoration-status.expired { background: #7f1d1d; color: #fca5a5; } + /* 装扮有效期提示 */ + .decoration-duration { + font-size: 10px; + color: #6366f1; + margin-top: 2px; + } + .decoration-expiry { font-size: 10px; color: #9ca3af; margin-top: 2px; } + .shop-btn { display: flex; align-items: center; @@ -598,11 +663,22 @@ {{-- Toast --}}
+ {{-- Tab 导航 --}} +
+ + +
+ {{-- 商品网格 --}}
加载中…
+ {{-- 装扮网格 --}} +
+
加载中…
+
+ {{-- 改名内嵌遮罩 --}}
diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 03c8154..2e4feb4 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -24,6 +24,238 @@ @version 2.0.0 --}} +{{-- 个人装扮样式(消息气泡 / 昵称颜色 / 头像框) --}} + +